@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,310 @@
1
+ """Shared render-op setup: import, camera, lights, world, engine.
2
+
3
+ Factored out of `render_image.py` so `render_passes` and `render_animation`
4
+ run the same scene setup (D5). Behavior is unchanged: factory settings with
5
+ `use_empty=True`, glTF import, then the camera `camera_mode` selects (D4).
6
+ Lights come from the scene when it has any, else the `lighting` preset.
7
+
8
+ `setup_render_scene(job, workdir)` runs the full setup and returns
9
+ `(scene, meshes, center, radius, camera_obj)`. Ops that need per-frame work
10
+ (render_animation's orbit sweep) use the smaller pieces directly.
11
+ """
12
+
13
+ import os
14
+
15
+ import bpy
16
+ from mathutils import Vector
17
+
18
+ from errors import BadJob, ImportFailed, NoCamera, NoGeometry, RenderFailed
19
+ from framing import apply_camera_lens, compute_framing, orbit_offset
20
+
21
+
22
+ ORBIT_CAMERA_NAME = "NodeTool_Orbit"
23
+
24
+ CYCLES_SEED = 0
25
+
26
+
27
+ def _srgb_to_linear(channel):
28
+ """One sRGB channel in `[0, 1]` to scene-linear (the sRGB EOTF)."""
29
+ if channel <= 0.04045:
30
+ return channel / 12.92
31
+ return ((channel + 0.055) / 1.055) ** 2.4
32
+
33
+
34
+ def _hex_to_rgb(value):
35
+ # A hex color is sRGB; the Background node takes scene-linear values
36
+ # under the Standard view transform, so each channel goes through the
37
+ # sRGB EOTF. Writing sRGB straight in renders bright: measured,
38
+ # `#808080` came out as (188, 188, 188) instead of (128, 128, 128).
39
+ text = value.strip().lstrip("#")
40
+ if len(text) == 3:
41
+ text = "".join(ch * 2 for ch in text)
42
+ if len(text) != 6:
43
+ raise BadJob("background_color %r is not a hex color" % (value,))
44
+ try:
45
+ channels = [_srgb_to_linear(int(text[i : i + 2], 16) / 255) for i in (0, 2, 4)]
46
+ except ValueError:
47
+ raise BadJob("background_color %r is not a hex color" % (value,))
48
+ return tuple(channels) + (1.0,)
49
+
50
+
51
+ def mesh_objects(scene):
52
+ return [
53
+ obj
54
+ for obj in scene.objects
55
+ if obj.type == "MESH" and len(obj.data.polygons) > 0
56
+ ]
57
+
58
+
59
+ def ensure_materials(meshes):
60
+ """Give material-less meshes a neutral white material.
61
+
62
+ The three.js preview renders mesh data with a default material when the
63
+ glTF carries none; EEVEE/Cycles leave those faces unlit (effectively
64
+ invisible), so the op does the same fallback before any render time.
65
+ """
66
+ fallback = None
67
+ for obj in meshes:
68
+ slots = obj.material_slots
69
+ if slots and all(slot.material is not None for slot in slots):
70
+ continue
71
+ if fallback is None:
72
+ fallback = bpy.data.materials.new("NodeTool_Default")
73
+ fallback.use_nodes = True
74
+ principled = fallback.node_tree.nodes.get("Principled BSDF")
75
+ if principled is not None:
76
+ principled.inputs["Base Color"].default_value = (0.9, 0.9, 0.9, 1.0)
77
+ principled.inputs["Roughness"].default_value = 0.8
78
+ if not slots:
79
+ obj.data.materials.append(fallback)
80
+ else:
81
+ for slot in slots:
82
+ if slot.material is None:
83
+ slot.material = fallback
84
+
85
+
86
+ def scene_bounds(meshes):
87
+ corners = []
88
+ for obj in meshes:
89
+ for corner in obj.bound_box:
90
+ corners.append(obj.matrix_world @ Vector(corner))
91
+ if not corners:
92
+ raise NoGeometry("the model contains no visible geometry")
93
+ lo = Vector(
94
+ (
95
+ min(c.x for c in corners),
96
+ min(c.y for c in corners),
97
+ min(c.z for c in corners),
98
+ )
99
+ )
100
+ hi = Vector(
101
+ (
102
+ max(c.x for c in corners),
103
+ max(c.y for c in corners),
104
+ max(c.z for c in corners),
105
+ )
106
+ )
107
+ center = (lo + hi) / 2
108
+ radius = max((c - center).length for c in corners)
109
+ return center, radius
110
+
111
+
112
+ def scene_cameras(scene):
113
+ # Importer order, not alphabetical: D4 means the first camera in the
114
+ # document, and `scene.objects` lists cameras in creation order (the
115
+ # glTF importer creates one object per node in document order). Sorting
116
+ # by name rendered `Shot01` for a document that opens on `Shot02`.
117
+ return [obj for obj in scene.objects if obj.type == "CAMERA"]
118
+
119
+
120
+ def orbit_location(center, radius, params, aspect):
121
+ """World-space location of the orbit camera for the current props."""
122
+ framing = compute_framing(radius, params["fov"], aspect, params["zoom"])
123
+ offset = orbit_offset(params["azimuth"], params["elevation"], framing["distance"])
124
+ # Rigid three.js (Y up) to Blender (Z up) rotation: (x, y, z) -> (x, -z, y).
125
+ # Up stays up, azimuth 0 sits on -Y; see the framing module docstring.
126
+ return center + Vector((offset["x"], -offset["z"], offset["y"])), framing
127
+
128
+
129
+ def make_orbit_camera(scene, center, radius, params, aspect):
130
+ location, framing = orbit_location(center, radius, params, aspect)
131
+ data = bpy.data.cameras.new(ORBIT_CAMERA_NAME)
132
+ apply_camera_lens(data, params["fov"], framing)
133
+ obj = bpy.data.objects.new(ORBIT_CAMERA_NAME, data)
134
+ scene.collection.objects.link(obj)
135
+ obj.location = location
136
+ aim_camera(obj, center)
137
+ return obj
138
+
139
+
140
+ def aim_camera(obj, center):
141
+ direction = center - obj.location
142
+ if direction.length < 1e-9:
143
+ raise RenderFailed("orbit camera coincides with the scene center")
144
+ obj.rotation_euler = direction.to_track_quat("-Z", "Y").to_euler()
145
+
146
+
147
+ def select_camera(scene, center, radius, params, aspect):
148
+ mode = params.get("camera_mode", "auto")
149
+ if mode == "orbit":
150
+ return make_orbit_camera(scene, center, radius, params, aspect)
151
+ cameras = scene_cameras(scene)
152
+ if mode == "scene":
153
+ if not cameras:
154
+ raise NoCamera(
155
+ "camera_mode is 'scene' but the model has no camera; "
156
+ "use 'auto' or 'orbit' instead"
157
+ )
158
+ return cameras[0]
159
+ if mode == "auto":
160
+ if cameras:
161
+ return cameras[0]
162
+ return make_orbit_camera(scene, center, radius, params, aspect)
163
+ raise BadJob("unknown camera_mode %r" % (mode,))
164
+
165
+
166
+ def has_scene_lights(scene):
167
+ return any(obj.type == "LIGHT" for obj in scene.objects)
168
+
169
+
170
+ def scene_animations(scene):
171
+ """Objects carrying an imported glTF animation action."""
172
+ found = []
173
+ for obj in scene.objects:
174
+ anim = obj.animation_data
175
+ if anim is not None and anim.action is not None:
176
+ found.append(obj)
177
+ return found
178
+
179
+
180
+ def _add_sun(name, direction, energy):
181
+ # `direction` points from the scene toward the light (camera side, like
182
+ # the three.js key/fill/rim offsets). A sun's rays travel along its
183
+ # local -Z, so +Z aligns with `direction` and the rays fly back into the
184
+ # scene, lighting the faces the camera sees.
185
+ data = bpy.data.lights.new(name, "SUN")
186
+ data.energy = energy
187
+ obj = bpy.data.objects.new(name, data)
188
+ bpy.context.scene.collection.objects.link(obj)
189
+ obj.rotation_euler = direction.to_track_quat("Z", "Y").to_euler()
190
+ return obj
191
+
192
+
193
+ def apply_lighting_preset(scene, camera_obj, center, params):
194
+ """Camera-relative key/fill/rim suns mirroring `addLights` in three.js."""
195
+ to_camera = (camera_obj.location - center).normalized()
196
+ up = Vector((0, 0, 1))
197
+ side = up.cross(to_camera)
198
+ if side.length < 1e-6:
199
+ side = Vector((1, 0, 0))
200
+ side = side.normalized()
201
+ intensity = params["light_intensity"]
202
+ preset = params["lighting"]
203
+
204
+ def place(name, color, energy, direction):
205
+ light = _add_sun(name, direction, energy * intensity)
206
+ light.data.color = color
207
+ return light
208
+
209
+ if preset == "studio":
210
+ key = to_camera + side * 0.8 + up * 0.9
211
+ fill = to_camera - side * 1.1 + up * 0.2
212
+ rim = -to_camera + up * 1.2
213
+ place("NodeTool_Key", (1, 1, 1), 3.0, key.normalized())
214
+ place("NodeTool_Fill", (1, 1, 1), 1.0, fill.normalized())
215
+ place("NodeTool_Rim", (1, 1, 1), 2.0, rim.normalized())
216
+ elif preset == "soft":
217
+ place("NodeTool_Key", (1, 1, 1), 1.5, (to_camera + up).normalized())
218
+ elif preset == "flat":
219
+ pass
220
+ else:
221
+ raise BadJob("unknown lighting preset %r" % (preset,))
222
+
223
+
224
+ def apply_world(background_color, transparent):
225
+ world = bpy.data.worlds.new("NodeTool_World")
226
+ world.use_nodes = True
227
+ background = world.node_tree.nodes.get("Background")
228
+ if background is not None:
229
+ background.inputs["Color"].default_value = _hex_to_rgb(background_color)
230
+ background.inputs["Strength"].default_value = 1.0
231
+ bpy.context.scene.world = world
232
+ bpy.context.scene.render.film_transparent = bool(transparent)
233
+ # `Standard` avoids the default filmic-style desaturation (which renders
234
+ # #ffffff as mid gray), but it is still a display transform over
235
+ # scene-linear values — not a passthrough. The hex therefore reaches the
236
+ # Background node linearized (see `_hex_to_rgb`); without that step the
237
+ # rendered background reads bright.
238
+ bpy.context.scene.view_settings.view_transform = "Standard"
239
+
240
+
241
+ def apply_engine(params):
242
+ engine = params["engine"]
243
+ scene = bpy.context.scene
244
+ if engine == "eevee":
245
+ scene.render.engine = "BLENDER_EEVEE"
246
+ scene.eevee.taa_render_samples = max(1, int(params["samples"]))
247
+ elif engine == "cycles":
248
+ scene.render.engine = "CYCLES"
249
+ scene.cycles.device = "CPU"
250
+ scene.cycles.samples = max(1, int(params["samples"]))
251
+ scene.cycles.use_denoising = bool(params["denoise"])
252
+ scene.cycles.seed = CYCLES_SEED
253
+ else:
254
+ raise BadJob("unknown engine %r" % (engine,))
255
+
256
+
257
+ def apply_resolution(params):
258
+ scene = bpy.context.scene
259
+ width = max(1, int(params["width"]))
260
+ height = max(1, int(params["height"]))
261
+ scene.render.resolution_x = width
262
+ scene.render.resolution_y = height
263
+ scene.render.resolution_percentage = max(1, int(params["resolution_percentage"]))
264
+ return width, height
265
+
266
+
267
+ def import_model(job, workdir, fps=None):
268
+ """Factory reset, glTF import, and the `no_geometry` gate.
269
+
270
+ `fps` is applied before the import when given: the importer maps a glTF
271
+ animation channel timestamp `t` seconds onto frame `round(t * fps)` (D4),
272
+ so the scene fps must already hold at import time.
273
+ """
274
+ model_path = os.path.join(workdir, job["inputs"]["model"])
275
+ if not os.path.exists(model_path):
276
+ raise BadJob("model input %r is missing" % (job["inputs"]["model"],))
277
+
278
+ bpy.ops.wm.read_factory_settings(use_empty=True)
279
+ if fps is not None:
280
+ bpy.context.scene.render.fps = max(1, int(fps))
281
+
282
+ try:
283
+ result = bpy.ops.import_scene.gltf(filepath=model_path)
284
+ except Exception as exc:
285
+ raise ImportFailed("glTF import failed: %s" % (exc,))
286
+ if "FINISHED" not in result:
287
+ raise ImportFailed("glTF importer rejected %r" % (job["inputs"]["model"],))
288
+
289
+ scene = bpy.context.scene
290
+ meshes = mesh_objects(scene)
291
+ if not meshes:
292
+ raise NoGeometry("the model contains no visible geometry")
293
+ ensure_materials(meshes)
294
+ return scene, meshes
295
+
296
+
297
+ def setup_render_scene(job, workdir, fps=None):
298
+ """Full still-style setup. Returns `(scene, meshes, center, radius, camera)`."""
299
+ params = job["job"]["params"]
300
+ scene, meshes = import_model(job, workdir, fps=fps)
301
+ width, height = apply_resolution(params)
302
+ aspect = width / height
303
+ center, radius = scene_bounds(meshes)
304
+ camera_obj = select_camera(scene, center, radius, params, aspect)
305
+ scene.camera = camera_obj
306
+ if not has_scene_lights(scene):
307
+ apply_lighting_preset(scene, camera_obj, center, params)
308
+ apply_world(params["background_color"], params["transparent"])
309
+ apply_engine(params)
310
+ return scene, meshes, center, radius, camera_obj
@@ -0,0 +1,95 @@
1
+ """The `export_model` op: glTF bytes in, one exported file out (D8).
2
+
3
+ `params["format"]` is `fbx`, `obj`, or `usd`. The node persists the bytes
4
+ through `context.createAsset` and returns an `AssetRef` — never a
5
+ `Model3DRef`, which keeps meaning glTF everywhere. GLB is deliberately not
6
+ a format here: `prepare_for_engine` and `FormatConverter` already produce a
7
+ `Model3DRef` for it.
8
+
9
+ The operators are the ones Blender 5.x ships: the FBX IO add-on (enabled
10
+ under `--factory-startup`) and the core `wm.obj_export` / `wm.usd_export`
11
+ operators, which need no add-on. A call that raises (a failing poll, a
12
+ rejected scene) surfaces as `export_failed` with the operator's message.
13
+
14
+ OBJ export is geometry-only (`export_materials=False`): the operator would
15
+ otherwise write a `model.mtl` the job never declares, and the `.obj` would
16
+ keep a dangling `mtllib` line pointing at a file that dies with the scratch
17
+ directory.
18
+ """
19
+
20
+ import os
21
+ import time
22
+
23
+ import bpy
24
+
25
+ from errors import BadJob, ExportFailed
26
+ from ops.common import import_model
27
+
28
+
29
+ _FORMAT_OPS = {
30
+ "fbx": "export_scene.fbx",
31
+ "obj": "wm.obj_export",
32
+ "usd": "wm.usd_export",
33
+ }
34
+
35
+
36
+ def _exporter(format):
37
+ try:
38
+ dotted = _FORMAT_OPS[format]
39
+ except KeyError:
40
+ raise BadJob(
41
+ "unknown export format %r: choose from %s" % (format, sorted(_FORMAT_OPS))
42
+ )
43
+ group, name = dotted.split(".")
44
+ return getattr(getattr(bpy.ops, group), name)
45
+
46
+
47
+ def _export_kwargs(format, out_path):
48
+ # OBJ export is geometry-only by decision: `wm.obj_export` writes
49
+ # `model.mtl` next to `model.obj`, but the job declares (and the node
50
+ # returns) only `file`, so the material file would vanish with the
51
+ # scratch directory while the `.obj` kept a dangling `mtllib` line.
52
+ # Disabling materials keeps the contract to one file.
53
+ if format == "obj":
54
+ return {"filepath": out_path, "export_materials": False}
55
+ return {"filepath": out_path}
56
+
57
+
58
+ def run(job, workdir):
59
+ """Run `export_model`. Returns `(produced, stats)`."""
60
+ params = job["job"]["params"]
61
+ outputs = job["outputs"]
62
+ if "file" not in outputs:
63
+ raise BadJob("export_model declares no 'file' output")
64
+ format = params.get("format")
65
+ if format not in _FORMAT_OPS:
66
+ raise BadJob(
67
+ "unknown export format %r: choose from %s" % (format, sorted(_FORMAT_OPS))
68
+ )
69
+
70
+ # The empty scene gates as `no_geometry` inside `import_model`, before
71
+ # any export time is spent.
72
+ _scene, meshes = import_model(job, workdir)
73
+
74
+ out_path = os.path.join(workdir, outputs["file"])
75
+ export = _exporter(format)
76
+
77
+ started = time.monotonic()
78
+ try:
79
+ result = export(**_export_kwargs(format, out_path))
80
+ except Exception as exc:
81
+ raise ExportFailed("%s export failed: %s" % (format, exc))
82
+ render_seconds = time.monotonic() - started
83
+
84
+ if "FINISHED" not in result:
85
+ raise ExportFailed("%s exporter rejected the scene" % (format,))
86
+ if not os.path.exists(out_path):
87
+ raise ExportFailed(
88
+ "%s export finished but %r is missing" % (format, outputs["file"])
89
+ )
90
+ stats = {
91
+ "blender_version": bpy.app.version_string,
92
+ "render_seconds": render_seconds,
93
+ "objects": len(meshes),
94
+ }
95
+ return ["file"], stats