@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
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"""The `render_passes` op: glTF bytes in, control passes out (D4).
|
|
2
|
+
|
|
3
|
+
Same scene setup as `render_image` (`ops/common.py`), plus a compositor
|
|
4
|
+
node tree staging the raw float passes:
|
|
5
|
+
|
|
6
|
+
- `color`: the beauty render, straight through `scene.render.filepath`.
|
|
7
|
+
- `depth`: linear distance along the camera view axis, in scene units, from
|
|
8
|
+
the Z pass. `png16` (default) normalizes to `[0, 65535]` between
|
|
9
|
+
`depth_near` and `depth_far` — the min and max finite depth over the
|
|
10
|
+
foreground pixels, both returned in stats — with background `65535`.
|
|
11
|
+
`exr` keeps the raw float with background `+inf`, rewritten from the
|
|
12
|
+
`1e10` sentinel the staged file carries (see `BACKGROUND_DEPTH`).
|
|
13
|
+
- `normal`: camera-space normals from the Normal pass, `[-1, 1]` mapped to
|
|
14
|
+
8-bit RGB, background `(128, 128, 255)`.
|
|
15
|
+
- `mask`: 8-bit image, foreground `255`, from the object index pass — on
|
|
16
|
+
Cycles, where the pass exists. EEVEE's `CompositorNodeRLayers` exposes no
|
|
17
|
+
index socket (measured 5.2.1: `use_pass_object_index = True` is accepted
|
|
18
|
+
yet the node lists only Image/Alpha/Depth), so EEVEE keys the mask on
|
|
19
|
+
finite Z instead. The gate agrees with the index pass on opaque geometry
|
|
20
|
+
and disagrees for alpha-blended, holdout, and volume materials — a
|
|
21
|
+
recorded EEVEE-only deviation from D4, not an implementation of it.
|
|
22
|
+
|
|
23
|
+
`params["passes"]` selects the subset to produce; the op writes and reports
|
|
24
|
+
only those.
|
|
25
|
+
|
|
26
|
+
Blender 5.x notes (measured, not assumed):
|
|
27
|
+
|
|
28
|
+
- Compositing runs through `scene.compositing_node_group`; the legacy
|
|
29
|
+
`use_nodes`/`node_tree` pair is deprecated and `Scene.node_tree` is gone.
|
|
30
|
+
The legacy Math/Mix/MapRange nodes are gone too, so the tree stages RAW
|
|
31
|
+
passes only and every contract mapping runs in Python (`depth.py`).
|
|
32
|
+
- A File Output node writes `<render-filepath>/<file_name>.<ext>`: the
|
|
33
|
+
base is the current render filepath, and an absolute `file_name` is
|
|
34
|
+
mangled (measured: `/tmp/wd/stage/x` lands at `/tmp/tmp/wd/stage/x.exr`
|
|
35
|
+
under the factory `/tmp/` base). The op therefore points the render
|
|
36
|
+
filepath at the workdir itself and stages through a relative subdir of
|
|
37
|
+
it, so a killed run leaves nothing outside the workdir — which the
|
|
38
|
+
runner deletes on every exit path.
|
|
39
|
+
- A File Output node ignores its item-level format and always writes
|
|
40
|
+
multilayer EXR, so both staged passes are float EXR by construction.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
import os
|
|
44
|
+
import shutil
|
|
45
|
+
import time
|
|
46
|
+
|
|
47
|
+
import bpy
|
|
48
|
+
from mathutils import Vector
|
|
49
|
+
|
|
50
|
+
from depth import depth_range, normalize_to_u16, write_gray8_png, write_gray16_png
|
|
51
|
+
from depth import write_rgb8_png
|
|
52
|
+
from errors import BadJob, RenderFailed
|
|
53
|
+
from exr import read_exr_rgba, rewrite_background_to_inf
|
|
54
|
+
from ops.common import setup_render_scene
|
|
55
|
+
|
|
56
|
+
#: Staging directory for the raw float passes, inside the job's own
|
|
57
|
+
#: working directory: one Blender runs per op invocation, and the runner
|
|
58
|
+
#: deletes the workdir on every exit path, so no run can leak staged EXRs.
|
|
59
|
+
#: A relative name, so the File Output node resolves it under the workdir
|
|
60
|
+
#: the op points the render filepath at (see `run`).
|
|
61
|
+
_STAGE_SUBDIR = "nodetool-passes-stage"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
#: Raw float passes the compositor stages.
|
|
65
|
+
_DEPTH_RAW_SLOT = "depth_raw"
|
|
66
|
+
_NORMAL_RAW_SLOT = "normal_raw"
|
|
67
|
+
_INDEX_RAW_SLOT = "index_raw"
|
|
68
|
+
|
|
69
|
+
#: Object index every mesh carries when the mask stages the index pass:
|
|
70
|
+
#: background reads 0, so any positive index is foreground.
|
|
71
|
+
_MASK_PASS_INDEX = 1
|
|
72
|
+
|
|
73
|
+
#: Background constant for the normal map: exactly (128, 128, 255) in 8-bit.
|
|
74
|
+
NORMAL_BACKGROUND = (128, 128, 255)
|
|
75
|
+
|
|
76
|
+
#: No-hit Z value in the staged EXRs, measured on Blender 5.2.1: the
|
|
77
|
+
#: staged Z pass is finite everywhere and off-geometry pixels read
|
|
78
|
+
#: exactly this value on both EEVEE and Cycles. A real surface never
|
|
79
|
+
#: reaches it (the far clip sits at distance + radius * 10, single
|
|
80
|
+
#: digits), so foreground is finite depth below this value on both
|
|
81
|
+
#: engines. This constant is a measured staging behavior, not a
|
|
82
|
+
#: documented Blender API value: re-measure it if the version floor
|
|
83
|
+
#: moves.
|
|
84
|
+
BACKGROUND_DEPTH = 1e10
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_foreground(value):
|
|
88
|
+
return value == value and value != float("inf") and value != float("-inf") and value < BACKGROUND_DEPTH
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _selected_passes(params):
|
|
92
|
+
passes = params.get("passes", ["color", "depth", "normal", "mask"])
|
|
93
|
+
if not isinstance(passes, list) or not passes:
|
|
94
|
+
raise BadJob("render_passes needs a non-empty 'passes' list")
|
|
95
|
+
known = {"color", "depth", "normal", "mask"}
|
|
96
|
+
for name in passes:
|
|
97
|
+
if name not in known:
|
|
98
|
+
raise BadJob("unknown pass %r: choose from %s" % (name, sorted(known)))
|
|
99
|
+
return passes
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _build_compositor(stage_index):
|
|
103
|
+
# Passes are enabled before the Render Layers node is created so its
|
|
104
|
+
# sockets exist when linked. Both raw passes always stage: the mask and
|
|
105
|
+
# the depth stats share the depth resolve, so depth is needed even when
|
|
106
|
+
# only the mask is selected. `file_name` stays relative so it resolves
|
|
107
|
+
# under the workdir the render filepath points at; an absolute value
|
|
108
|
+
# would be mangled under the factory `/tmp/` base instead.
|
|
109
|
+
scene = bpy.context.scene
|
|
110
|
+
view_layer = scene.view_layers[0]
|
|
111
|
+
view_layer.use_pass_z = True
|
|
112
|
+
view_layer.use_pass_normal = True
|
|
113
|
+
# The index socket only exists on Cycles (measured 5.2.1: EEVEE lists
|
|
114
|
+
# Image/Alpha/Depth with the flag on), so it is enabled only when the
|
|
115
|
+
# mask will actually stage it — and before the node is created.
|
|
116
|
+
if stage_index:
|
|
117
|
+
view_layer.use_pass_object_index = True
|
|
118
|
+
|
|
119
|
+
group = bpy.data.node_groups.new("NodeTool_Passes", "CompositorNodeTree")
|
|
120
|
+
scene.compositing_node_group = group
|
|
121
|
+
layers = group.nodes.new("CompositorNodeRLayers")
|
|
122
|
+
|
|
123
|
+
staged = [(_DEPTH_RAW_SLOT, "Depth"), (_NORMAL_RAW_SLOT, "Normal")]
|
|
124
|
+
if stage_index:
|
|
125
|
+
if "Object Index" not in [output.name for output in layers.outputs]:
|
|
126
|
+
raise RenderFailed(
|
|
127
|
+
"object index pass has no socket on %s" % (scene.render.engine,)
|
|
128
|
+
)
|
|
129
|
+
staged.append((_INDEX_RAW_SLOT, "Object Index"))
|
|
130
|
+
for slot, socket in staged:
|
|
131
|
+
node = group.nodes.new("CompositorNodeOutputFile")
|
|
132
|
+
node.file_output_items.clear()
|
|
133
|
+
node.file_output_items.new("RGBA", "Image")
|
|
134
|
+
node.file_name = os.path.join(_STAGE_SUBDIR, slot)
|
|
135
|
+
group.links.new(layers.outputs[socket], node.inputs[0])
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _camera_basis(camera_obj):
|
|
139
|
+
"""Rows of the world-to-camera rotation: maps world normals to camera space."""
|
|
140
|
+
return camera_obj.matrix_world.to_3x3().transposed()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _to_camera_space(basis, normal):
|
|
144
|
+
vector = basis @ Vector(normal)
|
|
145
|
+
length = vector.length
|
|
146
|
+
if length < 1e-12:
|
|
147
|
+
return None
|
|
148
|
+
return (vector.x / length, vector.y / length, vector.z / length)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _map_normal(rgb, basis):
|
|
152
|
+
"""World-space normal to 8-bit RGB in camera space, `[-1, 1]` to `[0, 255]`."""
|
|
153
|
+
camera = _to_camera_space(basis, rgb)
|
|
154
|
+
if camera is None:
|
|
155
|
+
return None
|
|
156
|
+
out = []
|
|
157
|
+
for component in camera:
|
|
158
|
+
scaled = component * 0.5 + 0.5
|
|
159
|
+
if scaled < 0:
|
|
160
|
+
scaled = 0
|
|
161
|
+
elif scaled > 1:
|
|
162
|
+
scaled = 1
|
|
163
|
+
out.append(int(round(scaled * 255)))
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _collect_staged(workdir, stage_index):
|
|
168
|
+
"""Move the staged EXRs into the workdir; fail loudly when one is missing."""
|
|
169
|
+
stage_dir = os.path.join(workdir, _STAGE_SUBDIR)
|
|
170
|
+
try:
|
|
171
|
+
seen = sorted(os.listdir(stage_dir))
|
|
172
|
+
except OSError:
|
|
173
|
+
seen = []
|
|
174
|
+
staged = {}
|
|
175
|
+
slots = [_DEPTH_RAW_SLOT, _NORMAL_RAW_SLOT]
|
|
176
|
+
if stage_index:
|
|
177
|
+
slots.append(_INDEX_RAW_SLOT)
|
|
178
|
+
for slot in slots:
|
|
179
|
+
src = os.path.join(stage_dir, slot + ".exr")
|
|
180
|
+
if not os.path.exists(src):
|
|
181
|
+
# Name the directory: a missing stage file means the File Output
|
|
182
|
+
# resolved elsewhere, and the guess list is what debugs it.
|
|
183
|
+
raise RenderFailed(
|
|
184
|
+
"compositor stage file %r is missing (staged: %s)"
|
|
185
|
+
% (slot + ".exr", seen)
|
|
186
|
+
)
|
|
187
|
+
dst = os.path.join(workdir, slot + ".exr")
|
|
188
|
+
os.replace(src, dst)
|
|
189
|
+
staged[slot] = dst
|
|
190
|
+
shutil.rmtree(stage_dir, ignore_errors=True)
|
|
191
|
+
return staged
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def run(job, workdir):
|
|
195
|
+
"""Run `render_passes`. Returns `(produced, stats)`."""
|
|
196
|
+
params = job["job"]["params"]
|
|
197
|
+
outputs = job["outputs"]
|
|
198
|
+
passes = _selected_passes(params)
|
|
199
|
+
for name in passes:
|
|
200
|
+
if name not in outputs:
|
|
201
|
+
raise BadJob("render_passes pass %r declares no output file" % (name,))
|
|
202
|
+
depth_format = params.get("depth_format", "png16")
|
|
203
|
+
if "depth" in passes and depth_format not in ("png16", "exr"):
|
|
204
|
+
raise BadJob("unknown depth_format %r" % (depth_format,))
|
|
205
|
+
|
|
206
|
+
scene, meshes, center, radius, camera_obj = setup_render_scene(job, workdir)
|
|
207
|
+
stage_dir = os.path.join(workdir, _STAGE_SUBDIR)
|
|
208
|
+
os.makedirs(stage_dir, exist_ok=True)
|
|
209
|
+
try:
|
|
210
|
+
return _run_passes(job, workdir, params, outputs, passes, depth_format,
|
|
211
|
+
scene, meshes, camera_obj)
|
|
212
|
+
finally:
|
|
213
|
+
# The stage subdir never lingers outside the workdir, and the runner
|
|
214
|
+
# deletes the workdir itself on every exit path — including the
|
|
215
|
+
# SIGKILL path where this `finally` never runs.
|
|
216
|
+
shutil.rmtree(stage_dir, ignore_errors=True)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _run_passes(job, workdir, params, outputs, passes, depth_format,
|
|
220
|
+
scene, meshes, camera_obj):
|
|
221
|
+
# The File Output base is the render filepath, snapshotted when the
|
|
222
|
+
# node is created — so the filepath points at the workdir itself
|
|
223
|
+
# (trailing separator: a directory, not a file) before the compositor
|
|
224
|
+
# is built, and the relative stage names resolve inside it. Color is
|
|
225
|
+
# saved from the Render Result afterwards, so its location stays exact
|
|
226
|
+
# either way.
|
|
227
|
+
scene.render.image_settings.file_format = "PNG"
|
|
228
|
+
scene.render.image_settings.color_mode = "RGBA"
|
|
229
|
+
scene.render.image_settings.color_depth = "8"
|
|
230
|
+
scene.render.filepath = workdir + os.sep
|
|
231
|
+
# The mask stages the object index pass where it exists (Cycles) and
|
|
232
|
+
# shares the depth resolve where it does not (EEVEE).
|
|
233
|
+
stage_index = "mask" in passes and scene.render.engine == "CYCLES"
|
|
234
|
+
if stage_index:
|
|
235
|
+
for obj in meshes:
|
|
236
|
+
obj.pass_index = _MASK_PASS_INDEX
|
|
237
|
+
_build_compositor(stage_index)
|
|
238
|
+
|
|
239
|
+
started = time.monotonic()
|
|
240
|
+
try:
|
|
241
|
+
bpy.ops.render.render()
|
|
242
|
+
except Exception as exc:
|
|
243
|
+
raise RenderFailed("render failed: %s" % (exc,))
|
|
244
|
+
render_seconds = time.monotonic() - started
|
|
245
|
+
if "color" in passes:
|
|
246
|
+
try:
|
|
247
|
+
bpy.data.images["Render Result"].save_render(
|
|
248
|
+
os.path.join(workdir, outputs["color"])
|
|
249
|
+
)
|
|
250
|
+
except Exception as exc:
|
|
251
|
+
raise RenderFailed("color save failed: %s" % (exc,))
|
|
252
|
+
|
|
253
|
+
produced = []
|
|
254
|
+
stats = {
|
|
255
|
+
"blender_version": bpy.app.version_string,
|
|
256
|
+
"render_seconds": render_seconds,
|
|
257
|
+
"objects": len(meshes),
|
|
258
|
+
"camera": camera_obj.name,
|
|
259
|
+
}
|
|
260
|
+
if "color" in passes:
|
|
261
|
+
produced.append("color")
|
|
262
|
+
|
|
263
|
+
# Depth stages whenever the mask, the normal background, or depth
|
|
264
|
+
# itself needs it: all three share the foreground resolve.
|
|
265
|
+
need_depth = bool({"depth", "mask", "normal"} & set(passes))
|
|
266
|
+
need_normal = "normal" in passes
|
|
267
|
+
staged = (
|
|
268
|
+
_collect_staged(workdir, stage_index)
|
|
269
|
+
if (need_depth or need_normal)
|
|
270
|
+
else {}
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
depths, foreground, width, height = None, None, 0, 0
|
|
274
|
+
if need_depth:
|
|
275
|
+
width, height, depths, _depth_g, _depth_b = read_exr_rgba(
|
|
276
|
+
staged[_DEPTH_RAW_SLOT]
|
|
277
|
+
)
|
|
278
|
+
# Foreground is finite Z below the no-hit sentinel: the staged
|
|
279
|
+
# EXR carries 1e10 off-geometry on both engines (measured
|
|
280
|
+
# 5.2.1). The EEVEE mask shares this resolve; the Cycles mask
|
|
281
|
+
# keys on its own index pass below.
|
|
282
|
+
foreground = [_is_foreground(value) for value in depths]
|
|
283
|
+
if "mask" in passes:
|
|
284
|
+
if stage_index:
|
|
285
|
+
index_width, index_height, index_r, _g, _b = read_exr_rgba(
|
|
286
|
+
staged[_INDEX_RAW_SLOT]
|
|
287
|
+
)
|
|
288
|
+
if need_depth and (index_width, index_height) != (width, height):
|
|
289
|
+
raise RenderFailed(
|
|
290
|
+
"depth (%dx%d) and index (%dx%d) sizes disagree"
|
|
291
|
+
% (width, height, index_width, index_height)
|
|
292
|
+
)
|
|
293
|
+
width, height = index_width, index_height
|
|
294
|
+
# Background reads exactly 0; every mesh carries
|
|
295
|
+
# `_MASK_PASS_INDEX`, so any positive index is foreground.
|
|
296
|
+
foreground = [value > 0.5 for value in index_r]
|
|
297
|
+
else:
|
|
298
|
+
assert depths is not None and foreground is not None
|
|
299
|
+
assert foreground is not None
|
|
300
|
+
write_gray8_png(
|
|
301
|
+
os.path.join(workdir, outputs["mask"]),
|
|
302
|
+
width,
|
|
303
|
+
height,
|
|
304
|
+
[255 if on else 0 for on in foreground],
|
|
305
|
+
)
|
|
306
|
+
produced.append("mask")
|
|
307
|
+
if "normal" in passes:
|
|
308
|
+
assert foreground is not None
|
|
309
|
+
normal_width, normal_height, nx, ny, nz = read_exr_rgba(
|
|
310
|
+
staged[_NORMAL_RAW_SLOT]
|
|
311
|
+
)
|
|
312
|
+
if (normal_width, normal_height) != (width, height):
|
|
313
|
+
raise RenderFailed(
|
|
314
|
+
"depth (%dx%d) and normal (%dx%d) sizes disagree"
|
|
315
|
+
% (width, height, normal_width, normal_height)
|
|
316
|
+
)
|
|
317
|
+
# The Normal pass is world-space (measured: axis-aligned on an
|
|
318
|
+
# orbit render); D4 wants camera space, so rotate by the camera.
|
|
319
|
+
basis = _camera_basis(camera_obj)
|
|
320
|
+
rgb = []
|
|
321
|
+
for i in range(width * height):
|
|
322
|
+
if not foreground[i]:
|
|
323
|
+
rgb += list(NORMAL_BACKGROUND)
|
|
324
|
+
continue
|
|
325
|
+
mapped = _map_normal((nx[i], ny[i], nz[i]), basis)
|
|
326
|
+
if mapped is None:
|
|
327
|
+
rgb += list(NORMAL_BACKGROUND)
|
|
328
|
+
else:
|
|
329
|
+
rgb += mapped
|
|
330
|
+
write_rgb8_png(os.path.join(workdir, outputs["normal"]), width, height, rgb)
|
|
331
|
+
produced.append("normal")
|
|
332
|
+
if "depth" in passes:
|
|
333
|
+
assert depths is not None and foreground is not None
|
|
334
|
+
try:
|
|
335
|
+
near, far = depth_range(depths, foreground)
|
|
336
|
+
except ValueError:
|
|
337
|
+
raise RenderFailed("depth pass has no finite foreground depth")
|
|
338
|
+
stats["depth_near"] = near
|
|
339
|
+
stats["depth_far"] = far
|
|
340
|
+
if depth_format == "exr":
|
|
341
|
+
# The staged EXR carries the 1e10 no-hit sentinel where the
|
|
342
|
+
# contract promises +inf: rewrite the background samples
|
|
343
|
+
# first, keyed on the same foreground resolve the png16 path
|
|
344
|
+
# uses. Foreground samples are untouched.
|
|
345
|
+
rewrite_background_to_inf(staged[_DEPTH_RAW_SLOT], foreground)
|
|
346
|
+
os.replace(
|
|
347
|
+
staged[_DEPTH_RAW_SLOT], os.path.join(workdir, outputs["depth"])
|
|
348
|
+
)
|
|
349
|
+
else:
|
|
350
|
+
values = normalize_to_u16(depths, foreground, near, far)
|
|
351
|
+
write_gray16_png(
|
|
352
|
+
os.path.join(workdir, outputs["depth"]), width, height, values
|
|
353
|
+
)
|
|
354
|
+
os.remove(staged[_DEPTH_RAW_SLOT])
|
|
355
|
+
produced.append("depth")
|
|
356
|
+
|
|
357
|
+
# Drop whichever staged EXR survived (unselected depth keeps its raw,
|
|
358
|
+
# and the index raw is consumed only into the mask).
|
|
359
|
+
for slot in (_DEPTH_RAW_SLOT, _NORMAL_RAW_SLOT, _INDEX_RAW_SLOT):
|
|
360
|
+
leftover = os.path.join(workdir, slot + ".exr")
|
|
361
|
+
if os.path.exists(leftover):
|
|
362
|
+
os.remove(leftover)
|
|
363
|
+
return produced, stats
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Blender entry point: read `job.json`, run the op, always write `result.json`.
|
|
2
|
+
|
|
3
|
+
Invocation (built by `LocalBlenderRunner`):
|
|
4
|
+
|
|
5
|
+
blender -b --factory-startup --disable-autoexec \\
|
|
6
|
+
--python-exit-code 64 --python <asset_dir>/run_job.py -- job.json
|
|
7
|
+
|
|
8
|
+
`job.json` carries the `BlenderJob` (D4): version, logical input/output file
|
|
9
|
+
names, and `{ op, params }`. `result.json` carries the `BlenderResult`:
|
|
10
|
+
`ok: true` with the produced names and stats, or `ok: false` with a code and
|
|
11
|
+
a message. Exit code 64 is reserved for "the script raised", distinct from
|
|
12
|
+
Blender's own crash codes.
|
|
13
|
+
|
|
14
|
+
`sys.excepthook` is installed before the op modules are imported, so an
|
|
15
|
+
import error also lands in `result.json` instead of only on stderr.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import traceback
|
|
22
|
+
|
|
23
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
24
|
+
|
|
25
|
+
from errors import BlenderOpError # noqa: E402 — stdlib-only, cannot fail
|
|
26
|
+
|
|
27
|
+
JOB_VERSION = 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _result_path():
|
|
31
|
+
return os.path.join(os.getcwd(), "result.json")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _write_result(payload):
|
|
35
|
+
try:
|
|
36
|
+
with open(_result_path(), "w", encoding="utf-8") as handle:
|
|
37
|
+
json.dump(payload, handle)
|
|
38
|
+
except Exception as exc: # noqa: BLE001 — last resort must not raise
|
|
39
|
+
sys.stderr.write("run_job: cannot write result.json: %s\n" % (exc,))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _code_of(exc):
|
|
43
|
+
code = getattr(exc, "code", None)
|
|
44
|
+
if isinstance(code, str) and code:
|
|
45
|
+
return code
|
|
46
|
+
return "render_failed"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _install_excepthook():
|
|
50
|
+
def _hook(exc_type, exc_value, exc_tb):
|
|
51
|
+
message = "".join(
|
|
52
|
+
traceback.format_exception(exc_type, exc_value, exc_tb)
|
|
53
|
+
).strip()[-2000:]
|
|
54
|
+
_write_result(
|
|
55
|
+
{"ok": False, "error": {"code": _code_of(exc_value), "message": message}}
|
|
56
|
+
)
|
|
57
|
+
sys.__excepthook__(exc_type, exc_value, exc_tb)
|
|
58
|
+
|
|
59
|
+
sys.excepthook = _hook
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_install_excepthook()
|
|
63
|
+
|
|
64
|
+
from ops import DISPATCH # noqa: E402 — after the excepthook on purpose
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _job_path():
|
|
68
|
+
candidates = [arg for arg in sys.argv[1:] if arg != "--"]
|
|
69
|
+
if candidates:
|
|
70
|
+
path = candidates[-1]
|
|
71
|
+
else:
|
|
72
|
+
path = "job.json"
|
|
73
|
+
if not os.path.isabs(path):
|
|
74
|
+
path = os.path.join(os.getcwd(), path)
|
|
75
|
+
return path
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main():
|
|
79
|
+
path = _job_path()
|
|
80
|
+
try:
|
|
81
|
+
with open(path, encoding="utf-8") as handle:
|
|
82
|
+
job = json.load(handle)
|
|
83
|
+
except Exception as exc:
|
|
84
|
+
raise BlenderOpError("cannot read job.json: %s" % (exc,), "bad_job")
|
|
85
|
+
|
|
86
|
+
if not isinstance(job, dict) or job.get("version") != JOB_VERSION:
|
|
87
|
+
version = job.get("version") if isinstance(job, dict) else None
|
|
88
|
+
raise BlenderOpError(
|
|
89
|
+
"unsupported job version %r: run_job.py speaks version %d"
|
|
90
|
+
% (version, JOB_VERSION),
|
|
91
|
+
"bad_job",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
inner = job.get("job")
|
|
95
|
+
if not isinstance(inner, dict) or not isinstance(inner.get("op"), str):
|
|
96
|
+
raise BlenderOpError("job carries no op name", "bad_job")
|
|
97
|
+
op_name = inner["op"]
|
|
98
|
+
run = DISPATCH.get(op_name)
|
|
99
|
+
if run is None:
|
|
100
|
+
raise BlenderOpError(
|
|
101
|
+
"unknown op %r: this run_job.py serves %s" % (op_name, sorted(DISPATCH)),
|
|
102
|
+
"bad_job",
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
produced, stats = run(job, os.getcwd())
|
|
106
|
+
_write_result({"ok": True, "produced": produced, "stats": stats})
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
if __name__ == "__main__":
|
|
110
|
+
main()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Pin `apply_camera_lens` to the vertical fov, with no Blender in sight.
|
|
2
|
+
|
|
3
|
+
`framing.apply_camera_lens` writes onto a camera datablock but imports no bpy,
|
|
4
|
+
so this file runs under plain `python3` as well as under Blender's own
|
|
5
|
+
interpreter. `tests/camera-lens.test.ts` drives it both ways.
|
|
6
|
+
|
|
7
|
+
The stub below reproduces the two RNA properties that matter, from Blender's
|
|
8
|
+
`rna_camera.cc`: `angle` reads and writes `lens` against whichever sensor axis
|
|
9
|
+
`sensor_fit` names, while `angle_y` always uses `sensor_height`. That
|
|
10
|
+
difference is the bug this file guards: the still ops built the orbit camera
|
|
11
|
+
and wrote `angle` while the fit was still `AUTO` (so, the 36mm width), and the
|
|
12
|
+
sampled bake wrote `angle` again on the same camera once the fit was
|
|
13
|
+
`VERTICAL` (the 24mm height). Same params, two focal lengths, two pictures.
|
|
14
|
+
|
|
15
|
+
Plain asserts only: Blender's Python has no pytest.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), ".."))
|
|
23
|
+
|
|
24
|
+
from framing import apply_camera_lens, compute_framing
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FakeCameraData(object):
|
|
28
|
+
"""`bpy.types.Camera`'s lens properties, as Blender computes them."""
|
|
29
|
+
|
|
30
|
+
def __init__(self):
|
|
31
|
+
# What `bpy.data.cameras.new()` returns.
|
|
32
|
+
self.sensor_width = 36.0
|
|
33
|
+
self.sensor_height = 24.0
|
|
34
|
+
self.sensor_fit = "AUTO"
|
|
35
|
+
self.lens = 50.0
|
|
36
|
+
self.clip_start = 0.1
|
|
37
|
+
self.clip_end = 100.0
|
|
38
|
+
|
|
39
|
+
def _fit_sensor(self):
|
|
40
|
+
if self.sensor_fit == "VERTICAL":
|
|
41
|
+
return self.sensor_height
|
|
42
|
+
return self.sensor_width
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def angle(self):
|
|
46
|
+
return 2 * math.atan(self._fit_sensor() / (2 * self.lens))
|
|
47
|
+
|
|
48
|
+
@angle.setter
|
|
49
|
+
def angle(self, value):
|
|
50
|
+
self.lens = self._fit_sensor() / (2 * math.tan(value / 2))
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def angle_y(self):
|
|
54
|
+
return 2 * math.atan(self.sensor_height / (2 * self.lens))
|
|
55
|
+
|
|
56
|
+
@angle_y.setter
|
|
57
|
+
def angle_y(self, value):
|
|
58
|
+
self.lens = self.sensor_height / (2 * math.tan(value / 2))
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def check(label, actual, expected, tolerance=1e-6):
|
|
62
|
+
if abs(actual - expected) > tolerance:
|
|
63
|
+
print("MISMATCH %s: got %r want %r" % (label, actual, expected))
|
|
64
|
+
sys.exit(1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
FOV = 35.0
|
|
68
|
+
RADIUS = 1.5
|
|
69
|
+
ASPECT = 160.0 / 120.0
|
|
70
|
+
FRAMING = compute_framing(RADIUS, FOV, ASPECT, 1.0)
|
|
71
|
+
|
|
72
|
+
# 1. The requested fov is the vertical one, and the clips come from the framing.
|
|
73
|
+
fresh = FakeCameraData()
|
|
74
|
+
apply_camera_lens(fresh, FOV, FRAMING)
|
|
75
|
+
check("vertical fov", math.degrees(fresh.angle_y), FOV, 1e-9)
|
|
76
|
+
check("sensor fit", 1.0 if fresh.sensor_fit == "VERTICAL" else 0.0, 1.0)
|
|
77
|
+
check("clip_start", fresh.clip_start, FRAMING["near"])
|
|
78
|
+
check("clip_end", fresh.clip_end, FRAMING["far"])
|
|
79
|
+
|
|
80
|
+
# 2. The lens does not depend on the fit the camera arrived with. A camera the
|
|
81
|
+
# sampled bake re-places is already VERTICAL; a camera `make_orbit_camera`
|
|
82
|
+
# just created is AUTO. Writing `angle` gave those two 57.09mm and 38.06mm.
|
|
83
|
+
placed_again = FakeCameraData()
|
|
84
|
+
placed_again.sensor_fit = "VERTICAL"
|
|
85
|
+
apply_camera_lens(placed_again, FOV, FRAMING)
|
|
86
|
+
check("lens is fit-independent", placed_again.lens, fresh.lens, 1e-9)
|
|
87
|
+
check("lens", fresh.lens, 24.0 / (2 * math.tan(math.radians(FOV) / 2)), 1e-9)
|
|
88
|
+
|
|
89
|
+
# 3. The lens and the distance `compute_framing` returned describe one camera:
|
|
90
|
+
# the bounding sphere the distance was fitted to is inside the frame. The
|
|
91
|
+
# 36mm-wide reading failed this — 0.3007 of a sphere against a 0.2102 frame.
|
|
92
|
+
half_height = math.tan(fresh.angle_y / 2)
|
|
93
|
+
if RADIUS / FRAMING["distance"] > half_height + 1e-9:
|
|
94
|
+
print(
|
|
95
|
+
"MISMATCH sphere fit: radius/distance %r exceeds tan(vfov/2) %r"
|
|
96
|
+
% (RADIUS / FRAMING["distance"], half_height)
|
|
97
|
+
)
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
# 4. The fov clamp matches `compute_framing`'s, so a degenerate fov still
|
|
101
|
+
# leaves the lens and the distance agreeing.
|
|
102
|
+
tiny = FakeCameraData()
|
|
103
|
+
tiny_framing = compute_framing(RADIUS, 0.0, ASPECT, 1.0)
|
|
104
|
+
apply_camera_lens(tiny, 0.0, tiny_framing)
|
|
105
|
+
check("clamped fov", math.degrees(tiny.angle_y), 1.0, 1e-9)
|
|
106
|
+
|
|
107
|
+
print("camera lens ok")
|