@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.
Files changed (83) hide show
  1. package/README.md +36 -0
  2. package/blender_ops/depth.py +133 -0
  3. package/blender_ops/errors.py +46 -0
  4. package/blender_ops/exr.py +205 -0
  5. package/blender_ops/framing.py +75 -0
  6. package/blender_ops/ops/__init__.py +24 -0
  7. package/blender_ops/ops/common.py +310 -0
  8. package/blender_ops/ops/export_model.py +95 -0
  9. package/blender_ops/ops/prepare_for_engine.py +352 -0
  10. package/blender_ops/ops/render_animation.py +382 -0
  11. package/blender_ops/ops/render_image.py +41 -0
  12. package/blender_ops/ops/render_passes.py +363 -0
  13. package/blender_ops/run_job.py +110 -0
  14. package/blender_ops/tests/test_camera_lens.py +107 -0
  15. package/blender_ops/tests/test_depth.py +158 -0
  16. package/blender_ops/tests/test_exr.py +218 -0
  17. package/blender_ops/tests/test_framing.py +50 -0
  18. package/dist/.nodetool-build-stamp +1 -0
  19. package/dist/bake.d.ts +107 -0
  20. package/dist/bake.d.ts.map +1 -0
  21. package/dist/bake.js +209 -0
  22. package/dist/bake.js.map +1 -0
  23. package/dist/blender-binary.d.ts +38 -0
  24. package/dist/blender-binary.d.ts.map +1 -0
  25. package/dist/blender-binary.js +169 -0
  26. package/dist/blender-binary.js.map +1 -0
  27. package/dist/index.d.ts +31 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +40 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/job.d.ts +163 -0
  32. package/dist/job.d.ts.map +1 -0
  33. package/dist/job.js +67 -0
  34. package/dist/job.js.map +1 -0
  35. package/dist/nodes/bake-timeline-clip.d.ts +48 -0
  36. package/dist/nodes/bake-timeline-clip.d.ts.map +1 -0
  37. package/dist/nodes/bake-timeline-clip.js +152 -0
  38. package/dist/nodes/bake-timeline-clip.js.map +1 -0
  39. package/dist/nodes/blender-error.d.ts +21 -0
  40. package/dist/nodes/blender-error.d.ts.map +1 -0
  41. package/dist/nodes/blender-error.js +38 -0
  42. package/dist/nodes/blender-error.js.map +1 -0
  43. package/dist/nodes/defaults.d.ts +18 -0
  44. package/dist/nodes/defaults.d.ts.map +1 -0
  45. package/dist/nodes/defaults.js +18 -0
  46. package/dist/nodes/defaults.js.map +1 -0
  47. package/dist/nodes/export-model.d.ts +49 -0
  48. package/dist/nodes/export-model.d.ts.map +1 -0
  49. package/dist/nodes/export-model.js +127 -0
  50. package/dist/nodes/export-model.js.map +1 -0
  51. package/dist/nodes/prepare-for-engine.d.ts +52 -0
  52. package/dist/nodes/prepare-for-engine.d.ts.map +1 -0
  53. package/dist/nodes/prepare-for-engine.js +127 -0
  54. package/dist/nodes/prepare-for-engine.js.map +1 -0
  55. package/dist/nodes/progress.d.ts +11 -0
  56. package/dist/nodes/progress.d.ts.map +1 -0
  57. package/dist/nodes/progress.js +21 -0
  58. package/dist/nodes/progress.js.map +1 -0
  59. package/dist/nodes/render-animation.d.ts +52 -0
  60. package/dist/nodes/render-animation.d.ts.map +1 -0
  61. package/dist/nodes/render-animation.js +136 -0
  62. package/dist/nodes/render-animation.js.map +1 -0
  63. package/dist/nodes/render-base.d.ts +35 -0
  64. package/dist/nodes/render-base.d.ts.map +1 -0
  65. package/dist/nodes/render-base.js +95 -0
  66. package/dist/nodes/render-base.js.map +1 -0
  67. package/dist/nodes/render-image.d.ts +41 -0
  68. package/dist/nodes/render-image.d.ts.map +1 -0
  69. package/dist/nodes/render-image.js +98 -0
  70. package/dist/nodes/render-image.js.map +1 -0
  71. package/dist/nodes/render-passes.d.ts +54 -0
  72. package/dist/nodes/render-passes.d.ts.map +1 -0
  73. package/dist/nodes/render-passes.js +167 -0
  74. package/dist/nodes/render-passes.js.map +1 -0
  75. package/dist/run-job.d.ts +34 -0
  76. package/dist/run-job.d.ts.map +1 -0
  77. package/dist/run-job.js +137 -0
  78. package/dist/run-job.js.map +1 -0
  79. package/dist/runner.d.ts +124 -0
  80. package/dist/runner.d.ts.map +1 -0
  81. package/dist/runner.js +521 -0
  82. package/dist/runner.js.map +1 -0
  83. 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")