@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,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
|