@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,382 @@
1
+ """The `render_animation` op: glTF bytes in, one MP4 or a PNG sequence out.
2
+
3
+ Two modes, and the params say which. Without `frame_times` this is the
4
+ original video render (D4, D5): the scene fps is set to `fps` before the
5
+ import, so a glTF animation channel timestamp `t` seconds lands on frame
6
+ `round(t * fps)`; `frame_start` and `frame_end` are frames in that timeline;
7
+ and when the glTF has no animation and `camera_mode` is `orbit`, the orbit
8
+ camera turns `orbit_degrees` across the frame range instead. Video uses
9
+ Blender's own FFMPEG writer (MPEG-4 container, H.264, `yuv420p`), so the
10
+ package needs no ffmpeg on PATH and no Mediabunny dependency.
11
+
12
+ With `frame_times` it is the timeline bake's sampled producer (design §D6):
13
+ one still per entry, each at that entry's model time and its own camera, into
14
+ the `frame_*` outputs the job declares. A trimmed, sped-up, reversed or
15
+ looped clip is not a run of consecutive scene frames, so the caller sends the
16
+ evaluated list and the op sets `frame_set` per entry; the frames are muxed
17
+ outside Blender, which is what lets an alpha bake pick a different encoder.
18
+ """
19
+
20
+ import math
21
+ import os
22
+ import sys
23
+ import time
24
+
25
+ import bpy
26
+ from mathutils import Vector
27
+
28
+ from errors import BadJob, NoCamera, RenderFailed
29
+ from framing import apply_camera_lens
30
+ from ops.common import (
31
+ aim_camera,
32
+ apply_engine,
33
+ apply_lighting_preset,
34
+ apply_resolution,
35
+ apply_world,
36
+ has_scene_lights,
37
+ import_model,
38
+ make_orbit_camera,
39
+ orbit_location,
40
+ scene_animations,
41
+ scene_bounds,
42
+ scene_cameras,
43
+ select_camera,
44
+ )
45
+
46
+
47
+ def _frame_range(params):
48
+ start = int(params["frame_start"])
49
+ end = int(params["frame_end"])
50
+ if end < start:
51
+ raise BadJob(
52
+ "frame_end (%d) is before frame_start (%d)" % (end, start)
53
+ )
54
+ return start, end
55
+
56
+
57
+ def _action_fcurves(obj):
58
+ """Every fcurve on the object's action, legacy or layered (Blender 5).
59
+
60
+ Legacy actions carry `fcurves` directly; layered ones nest them in
61
+ `layers[].strips[].channelbag(slot)`. Both shapes key the same way.
62
+ """
63
+ action = obj.animation_data.action
64
+ if hasattr(action, "fcurves"):
65
+ return list(action.fcurves)
66
+ curves = []
67
+ slot = obj.animation_data.action_slot
68
+ for layer in action.layers:
69
+ for strip in layer.strips:
70
+ channelbag = getattr(strip, "channelbag", None)
71
+ if channelbag is None:
72
+ continue
73
+ try:
74
+ bag = channelbag(slot)
75
+ except Exception:
76
+ continue
77
+ curves.extend(bag.fcurves)
78
+ return curves
79
+
80
+
81
+ def _animate_orbit(scene, camera_obj, center, radius, params, aspect, start, end):
82
+ """Keyframe the orbit camera across the frame range (D4)."""
83
+ count = end - start + 1
84
+ for frame in range(start, end + 1):
85
+ fraction = (frame - start) / (count - 1) if count > 1 else 0.0
86
+ angled = dict(params, azimuth=params["azimuth"] + params["orbit_degrees"] * fraction)
87
+ location, _framing = orbit_location(center, radius, angled, aspect)
88
+ camera_obj.location = location
89
+ aim_camera(camera_obj, center)
90
+ camera_obj.keyframe_insert(data_path="location", frame=frame)
91
+ camera_obj.keyframe_insert(data_path="rotation_euler", frame=frame)
92
+ for curve in _action_fcurves(camera_obj):
93
+ for point in curve.keyframe_points:
94
+ point.interpolation = "LINEAR"
95
+
96
+
97
+ def run(job, workdir):
98
+ """Run `render_animation`. Returns `(produced, stats)`."""
99
+ params = job["job"]["params"]
100
+ outputs = job["outputs"]
101
+ frame_times = params.get("frame_times")
102
+ if frame_times is not None:
103
+ return _run_sampled(job, workdir, params, frame_times)
104
+ if "video" not in outputs:
105
+ raise BadJob("render_animation declares no 'video' output")
106
+ start, end = _frame_range(params)
107
+ fps = max(1, int(params["fps"]))
108
+
109
+ scene, meshes = import_model(job, workdir, fps=fps)
110
+ # Re-assert the fps after the import: the mapping the importer used is
111
+ # the one this scene holds for the render.
112
+ scene.render.fps = fps
113
+ width, height = apply_resolution(params)
114
+ aspect = width / height
115
+ center, radius = scene_bounds(meshes)
116
+
117
+ animated = scene_animations(scene)
118
+ mode = params.get("camera_mode", "auto")
119
+ if not animated and mode == "orbit":
120
+ camera_obj = make_orbit_camera(scene, center, radius, params, aspect)
121
+ scene.camera = camera_obj
122
+ _animate_orbit(scene, camera_obj, center, radius, params, aspect, start, end)
123
+ else:
124
+ camera_obj = select_camera(scene, center, radius, params, aspect)
125
+ scene.camera = camera_obj
126
+
127
+ if not has_scene_lights(scene):
128
+ apply_lighting_preset(scene, camera_obj, center, params)
129
+ apply_world(params["background_color"], params["transparent"])
130
+ apply_engine(params)
131
+
132
+ scene.frame_start = start
133
+ scene.frame_end = end
134
+ video_path = os.path.join(workdir, outputs["video"])
135
+ if not video_path.lower().endswith(".mp4"):
136
+ raise BadJob("render_animation output %r must end in .mp4" % (outputs["video"],))
137
+ scene.render.filepath = video_path
138
+ # Blender 5.x splits image and video output behind `media_type`: movie
139
+ # formats stay unsettable until the type flips to VIDEO (measured).
140
+ scene.render.image_settings.media_type = "VIDEO"
141
+ scene.render.image_settings.file_format = "FFMPEG"
142
+ scene.render.ffmpeg.format = "MPEG4"
143
+ scene.render.ffmpeg.codec = "H264"
144
+ scene.render.ffmpeg.constant_rate_factor = "MEDIUM"
145
+ scene.render.ffmpeg.ffmpeg_preset = "GOOD"
146
+
147
+ # Blender 5.x no longer prints `Fra:<n>` progress lines (it logs `Video
148
+ # append frame N` on stdout, which the runner does not watch), so the op
149
+ # reports each written frame itself on stderr. The local runner turns
150
+ # those into `onProgress` calls through `onStderrLine` (D6).
151
+ def _report_frame(*args):
152
+ scene = args[0] if args else None
153
+ frame = scene.frame_current if scene is not None else -1
154
+ sys.stderr.write("Fra:%d\n" % (frame,))
155
+ sys.stderr.flush()
156
+
157
+ handlers = bpy.app.handlers
158
+ handlers.render_write.append(_report_frame)
159
+ started = time.monotonic()
160
+ try:
161
+ bpy.ops.render.render(animation=True)
162
+ except Exception as exc:
163
+ raise RenderFailed("animation render failed: %s" % (exc,))
164
+ finally:
165
+ if _report_frame in handlers.render_write:
166
+ handlers.render_write.remove(_report_frame)
167
+ render_seconds = time.monotonic() - started
168
+
169
+ if not os.path.exists(video_path):
170
+ raise RenderFailed("animation finished but %r is missing" % (outputs["video"],))
171
+ stats = {
172
+ "blender_version": bpy.app.version_string,
173
+ "render_seconds": render_seconds,
174
+ "frames": end - start + 1,
175
+ "objects": len(meshes),
176
+ "camera": camera_obj.name,
177
+ }
178
+ return ["video"], stats
179
+
180
+
181
+ # ── Sampled mode (design §D6) ────────────────────────────────────────────────
182
+
183
+
184
+ def _frame_outputs(outputs):
185
+ """Declared `frame_*` outputs, in the order they play.
186
+
187
+ The caller declares one output per entry in `frame_times`, named
188
+ `frame_%06d`, and the op writes the file each one names. Sorting by the
189
+ logical name is what makes "in order" a property of the job rather than of
190
+ a dictionary's iteration.
191
+ """
192
+ frames = sorted(
193
+ (name, file) for name, file in outputs.items() if name.startswith("frame_")
194
+ )
195
+ if not frames:
196
+ raise BadJob("a sampled render_animation declares no 'frame_*' outputs")
197
+ return frames
198
+
199
+
200
+ def _select_animation(scene, name):
201
+ """Leave only `name` playing; with `name` None every animation plays.
202
+
203
+ Neither half of that holds after a plain glTF import, so this op sets both
204
+ rather than trusting the importer's state. The importer stashes every
205
+ animation on its own NLA track, *muted*, and then restores the first one
206
+ by assigning its action as the object's active action — so out of the box
207
+ only the first animation plays, and muting tracks changes nothing at all,
208
+ because the active action is evaluated on top of the NLA stack whatever
209
+ the tracks below it are doing. CI measured both halves at once: at a model
210
+ time of 1.0 the two-animation fixture rendered byte-identically with
211
+ `animation_name: "MoveA"` and with no name at all.
212
+
213
+ So each object is driven the way the importer's own restore drives it —
214
+ one action assigned as the active action, which is the mechanism every
215
+ other render here already relies on. The first wanted track becomes that
216
+ action and is muted so it is not evaluated twice; any further wanted track
217
+ is unmuted and plays from the stack; every unwanted track is muted and the
218
+ active action is cleared when none of them was wanted.
219
+ """
220
+ found = False
221
+ for obj in scene.objects:
222
+ anim = obj.animation_data
223
+ if anim is None or not anim.nla_tracks:
224
+ continue
225
+ chosen = None
226
+ for track in anim.nla_tracks:
227
+ wanted = name is None or track.name == name
228
+ if wanted and name is not None:
229
+ found = True
230
+ strip = next((s for s in track.strips if s.action), None)
231
+ if wanted and chosen is None and strip is not None:
232
+ chosen = strip
233
+ track.mute = True
234
+ else:
235
+ track.mute = not wanted
236
+ anim.action = chosen.action if chosen is not None else None
237
+ if chosen is not None:
238
+ # A Blender 5 action holds one slot per animated ID, and assigning
239
+ # the action alone leaves the slot to be guessed. The strip names
240
+ # the slot this object was imported with, so it is copied across
241
+ # where the build has slots at all.
242
+ slot = getattr(chosen, "action_slot", None)
243
+ if slot is not None and hasattr(anim, "action_slot"):
244
+ anim.action_slot = slot
245
+ if name is not None and not found:
246
+ raise BadJob(
247
+ "animation_name %r names no animation in this model" % (name,)
248
+ )
249
+
250
+
251
+ def _scene_camera(scene, name):
252
+ cameras = scene_cameras(scene)
253
+ if not cameras:
254
+ raise NoCamera(
255
+ "camera_mode is 'scene' but the model has no camera; "
256
+ "use 'auto' or 'orbit' instead"
257
+ )
258
+ if name is None:
259
+ return cameras[0]
260
+ for camera in cameras:
261
+ if camera.name == name:
262
+ return camera
263
+ raise NoCamera(
264
+ "scene_camera_name %r names no camera in this model (it has %s)"
265
+ % (name, ", ".join(cam.name for cam in cameras))
266
+ )
267
+
268
+
269
+ def _target_of(center, camera_params):
270
+ """Where the orbit camera looks: the bounds center plus `target_offset`.
271
+
272
+ The offset is authored in the three.js frame the clip's camera speaks, so
273
+ it goes through the same (x, y, z) -> (x, -z, y) rotation `orbit_location`
274
+ applies to the orbit offset itself.
275
+ """
276
+ offset = camera_params.get("target_offset")
277
+ if not offset:
278
+ return center
279
+ return center + Vector((offset[0], -offset[2], offset[1]))
280
+
281
+
282
+ def _place_orbit_camera(camera_obj, center, radius, camera_params, aspect):
283
+ """Move the orbit camera onto this entry's camera.
284
+
285
+ Same lens rule as `make_orbit_camera`, through the same function: this one
286
+ runs on a camera that already carries a `sensor_fit`, and a lens written
287
+ against the wrong sensor axis renders the model at a different size than
288
+ the still `render_image` draws from the same camera params.
289
+ """
290
+ location, framing = orbit_location(center, radius, camera_params, aspect)
291
+ camera_obj.location = location
292
+ apply_camera_lens(camera_obj.data, camera_params["fov"], framing)
293
+ aim_camera(camera_obj, _target_of(center, camera_params))
294
+
295
+
296
+ def _run_sampled(job, workdir, params, frame_times):
297
+ """One still per `frame_times` entry, each at its own model time and camera."""
298
+ outputs = job["outputs"]
299
+ frames = _frame_outputs(outputs)
300
+ if len(frames) != len(frame_times):
301
+ raise BadJob(
302
+ "render_animation declares %d frame outputs for %d frame_times"
303
+ % (len(frames), len(frame_times))
304
+ )
305
+ cameras = params.get("cameras") or []
306
+ if cameras and len(cameras) != len(frame_times):
307
+ raise BadJob(
308
+ "render_animation carries %d cameras for %d frame_times"
309
+ % (len(cameras), len(frame_times))
310
+ )
311
+
312
+ fps = max(1, int(params["fps"]))
313
+ scene, meshes = import_model(job, workdir, fps=fps)
314
+ scene.render.fps = fps
315
+ width, height = apply_resolution(params)
316
+ aspect = width / height
317
+ center, radius = scene_bounds(meshes)
318
+ _select_animation(scene, params.get("animation_name"))
319
+
320
+ mode = params.get("camera_mode", "auto")
321
+ first = cameras[0] if cameras else params
322
+ if mode == "scene":
323
+ camera_obj = _scene_camera(scene, first.get("scene_camera_name"))
324
+ else:
325
+ camera_obj = make_orbit_camera(scene, center, radius, first, aspect)
326
+ scene.camera = camera_obj
327
+
328
+ # Lights are placed once, from the first frame's camera, exactly as the
329
+ # keyframed orbit places them: a preset that followed the camera would
330
+ # relight the model every frame instead of turning it under fixed lamps.
331
+ if not has_scene_lights(scene):
332
+ apply_lighting_preset(scene, camera_obj, center, params)
333
+ apply_world(params["background_color"], params["transparent"])
334
+ apply_engine(params)
335
+ scene.render.image_settings.file_format = "PNG"
336
+ # RGBA either way, which is Blender's own PNG default and what
337
+ # `render_image` and `render_passes` write. What decides whether the
338
+ # ground is see-through is `film_transparent` (set by `apply_world` from
339
+ # `transparent`), not the channel count; dropping to RGB for an opaque
340
+ # bake only made its frames incomparable with the still `render_image`
341
+ # draws from the same camera. The opaque mux takes yuv420p and ignores
342
+ # the channel.
343
+ scene.render.image_settings.color_mode = "RGBA"
344
+
345
+ locations = []
346
+ started = time.monotonic()
347
+ for index, (_name, file_name) in enumerate(frames):
348
+ model_time = float(frame_times[index])
349
+ # The importer maps a glTF timestamp t onto frame `t * fps`, so the
350
+ # model time is a frame plus a subframe rather than a whole frame:
351
+ # `frame_set` is the only thing standing between a 24 fps scene and a
352
+ # sample list that asks for 1/60 s steps.
353
+ exact = model_time * fps
354
+ whole = int(math.floor(exact))
355
+ scene.frame_set(whole, subframe=exact - whole)
356
+ if cameras and mode != "scene":
357
+ _place_orbit_camera(camera_obj, center, radius, cameras[index], aspect)
358
+ locations.append(tuple(round(value, 6) for value in camera_obj.location))
359
+ scene.render.filepath = os.path.join(workdir, file_name)
360
+ try:
361
+ bpy.ops.render.render(write_still=True)
362
+ except Exception as exc:
363
+ raise RenderFailed(
364
+ "sampled render failed at frame %d: %s" % (index, exc)
365
+ )
366
+ if not os.path.exists(os.path.join(workdir, file_name)):
367
+ raise RenderFailed("frame %d finished but %r is missing" % (index, file_name))
368
+ # The runner turns `Fra:` on stderr into progress; the sampled loop
369
+ # writes it itself for the same reason the video path does.
370
+ sys.stderr.write("Fra:%d\n" % (index + 1,))
371
+ sys.stderr.flush()
372
+ render_seconds = time.monotonic() - started
373
+
374
+ stats = {
375
+ "blender_version": bpy.app.version_string,
376
+ "render_seconds": render_seconds,
377
+ "frames": len(frames),
378
+ "objects": len(meshes),
379
+ "camera": camera_obj.name,
380
+ "frame_camera_locations": locations,
381
+ }
382
+ return [name for name, _file in frames], stats
@@ -0,0 +1,41 @@
1
+ """The `render_image` op: glTF bytes in, one PNG out.
2
+
3
+ Scene setup is the shared render-op setup from `common.py` (D5). A glTF
4
+ with no mesh is `no_geometry`, raised before any render time.
5
+ """
6
+
7
+ import os
8
+ import time
9
+
10
+ import bpy
11
+
12
+ from errors import BadJob, RenderFailed
13
+ from ops.common import setup_render_scene
14
+
15
+
16
+ def run(job, workdir):
17
+ """Run `render_image`. Returns `(produced, stats)`."""
18
+ outputs = job["outputs"]
19
+ if "image" not in outputs:
20
+ raise BadJob("render_image declares no 'image' output")
21
+
22
+ scene, meshes, center, radius, camera_obj = setup_render_scene(job, workdir)
23
+
24
+ scene.render.image_settings.file_format = "PNG"
25
+ out_path = os.path.join(workdir, outputs["image"])
26
+ scene.render.filepath = out_path
27
+
28
+ started = time.monotonic()
29
+ try:
30
+ bpy.ops.render.render(write_still=True)
31
+ except Exception as exc:
32
+ raise RenderFailed("render failed: %s" % (exc,))
33
+ render_seconds = time.monotonic() - started
34
+
35
+ stats = {
36
+ "blender_version": bpy.app.version_string,
37
+ "render_seconds": render_seconds,
38
+ "objects": len(meshes),
39
+ "camera": camera_obj.name,
40
+ }
41
+ return ["image"], stats