@deepblend/dsh-blender-provider-local 0.1.0
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/lib/index.js +1610 -0
- package/package.json +25 -0
- package/python/__pycache__/bootstrap.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_capabilities.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_render.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_render.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_util.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_util.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_validate.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_views.cpython-313.pyc +0 -0
- package/python/bootstrap.py +625 -0
- package/python/deepblend_capabilities.py +633 -0
- package/python/deepblend_frames.py +402 -0
- package/python/deepblend_render.py +299 -0
- package/python/deepblend_scene.py +1537 -0
- package/python/deepblend_util.py +264 -0
- package/python/deepblend_validate.py +358 -0
- package/python/deepblend_views.py +745 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""DeepBlend preview renderer.
|
|
4
|
+
|
|
5
|
+
Renders exactly one frame from one camera out of an already-compiled ``.blend``
|
|
6
|
+
checkpoint. It never modifies the scene's geometry — a preview must show the
|
|
7
|
+
revision that was committed, not a new variant of it — with the single exception
|
|
8
|
+
of engine and sample overrides, which are render settings rather than scene
|
|
9
|
+
content and are always reported.
|
|
10
|
+
|
|
11
|
+
WHY ONE FRAME
|
|
12
|
+
-------------
|
|
13
|
+
M1's acceptance criterion is "可渲染主相机预览": a single camera preview plus a
|
|
14
|
+
checkpoint. Multi-view contact sheets and the visual-review loop are M2, and they
|
|
15
|
+
are a different feature, not a loop over this one. Keeping the render strictly
|
|
16
|
+
single-frame means a preview always costs one Blender launch and one image, which
|
|
17
|
+
is what makes it safe for the model to call on every iteration.
|
|
18
|
+
|
|
19
|
+
Runs inside Blender's embedded interpreter.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
|
|
25
|
+
import bpy
|
|
26
|
+
|
|
27
|
+
from deepblend_scene import describe_objects, resolve_engine
|
|
28
|
+
from deepblend_util import ActionError, error_text, report_progress
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def open_checkpoint(path):
|
|
32
|
+
"""Open a ``.blend`` checkpoint and return its scene.
|
|
33
|
+
|
|
34
|
+
``check_existing=False`` keeps the operator from silently doing nothing when
|
|
35
|
+
the path is already open, which would make a re-render of the same file
|
|
36
|
+
report the PREVIOUS frame's settings.
|
|
37
|
+
"""
|
|
38
|
+
if not os.path.isfile(path):
|
|
39
|
+
raise ActionError(
|
|
40
|
+
"REVISION_CHECKPOINT_MISSING",
|
|
41
|
+
'no .blend checkpoint exists at "%s"' % (path,),
|
|
42
|
+
{"checkpoint": path},
|
|
43
|
+
)
|
|
44
|
+
try:
|
|
45
|
+
bpy.ops.wm.open_mainfile(filepath=path, load_ui=False, check_existing=False)
|
|
46
|
+
except Exception as exc:
|
|
47
|
+
raise ActionError(
|
|
48
|
+
"BLENDER_SCRIPT_ERROR",
|
|
49
|
+
'could not open the checkpoint "%s": %s' % (path, error_text(exc)),
|
|
50
|
+
{"checkpoint": path},
|
|
51
|
+
)
|
|
52
|
+
scene = bpy.context.scene
|
|
53
|
+
if scene is None:
|
|
54
|
+
raise ActionError("BLENDER_SCRIPT_ERROR", 'the checkpoint "%s" opened without a scene' % (path,))
|
|
55
|
+
return scene
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def find_camera(scene, camera_id):
|
|
59
|
+
"""Find a camera object by its DeepBlend id, falling back to the scene camera.
|
|
60
|
+
|
|
61
|
+
Resolution order is explicit rather than "whatever Blender considers active":
|
|
62
|
+
a named camera that does not exist is an error (the caller asked for
|
|
63
|
+
something specific), while an unnamed request falls back to the scene's own
|
|
64
|
+
camera and then to the only camera present.
|
|
65
|
+
"""
|
|
66
|
+
for obj in scene.objects:
|
|
67
|
+
if obj.type != "CAMERA":
|
|
68
|
+
continue
|
|
69
|
+
if camera_id is not None and obj.get("deepblend_id") == camera_id:
|
|
70
|
+
return obj, None
|
|
71
|
+
|
|
72
|
+
if camera_id is not None:
|
|
73
|
+
available = sorted(
|
|
74
|
+
obj.get("deepblend_id") or obj.name for obj in scene.objects if obj.type == "CAMERA"
|
|
75
|
+
)
|
|
76
|
+
raise ActionError(
|
|
77
|
+
"SCENE_CAMERA_MISSING",
|
|
78
|
+
'the scene has no camera with id "%s"' % (camera_id,),
|
|
79
|
+
{"requested": camera_id, "available": available},
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if scene.camera is not None and scene.camera.type == "CAMERA":
|
|
83
|
+
return scene.camera, None
|
|
84
|
+
cameras = [obj for obj in scene.objects if obj.type == "CAMERA"]
|
|
85
|
+
if len(cameras) == 1:
|
|
86
|
+
return cameras[0], None
|
|
87
|
+
if len(cameras) == 0:
|
|
88
|
+
raise ActionError("SCENE_CAMERA_MISSING", "the scene contains no camera to render from")
|
|
89
|
+
raise ActionError(
|
|
90
|
+
"SCENE_CAMERA_MISSING",
|
|
91
|
+
"the scene contains %d cameras and none is active; name one explicitly" % (len(cameras),),
|
|
92
|
+
{"available": sorted(obj.get("deepblend_id") or obj.name for obj in cameras)},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def apply_render_overrides(scene, options, guard):
|
|
97
|
+
"""Apply engine, resolution, sample and frame overrides for this one render.
|
|
98
|
+
|
|
99
|
+
Overrides are reported as notices, always: a preview whose settings differ
|
|
100
|
+
from the profile that produced it is a difference the caller must be able to
|
|
101
|
+
see, even when the difference was requested.
|
|
102
|
+
"""
|
|
103
|
+
applied = {}
|
|
104
|
+
|
|
105
|
+
engine_key = options.get("engine")
|
|
106
|
+
if engine_key:
|
|
107
|
+
engine, downgrade = resolve_engine(engine_key, guard)
|
|
108
|
+
if downgrade is not None:
|
|
109
|
+
guard.warnings.append(downgrade)
|
|
110
|
+
if scene.render.engine != engine:
|
|
111
|
+
scene.render.engine = engine
|
|
112
|
+
applied["engine"] = engine
|
|
113
|
+
guard.note(
|
|
114
|
+
"SCENE_COMPILER_DECISION",
|
|
115
|
+
"preview rendered with engine override %s (checkpoint was compiled for %s)"
|
|
116
|
+
% (engine, checkpoint_profile(scene).get("engine", "<unknown>")),
|
|
117
|
+
{"engine": engine},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
width = options.get("width")
|
|
121
|
+
height = options.get("height")
|
|
122
|
+
if width is not None or height is not None:
|
|
123
|
+
new_width = int(width) if width is not None else scene.render.resolution_x
|
|
124
|
+
new_height = int(height) if height is not None else scene.render.resolution_y
|
|
125
|
+
if (new_width, new_height) != (scene.render.resolution_x, scene.render.resolution_y):
|
|
126
|
+
scene.render.resolution_x = new_width
|
|
127
|
+
scene.render.resolution_y = new_height
|
|
128
|
+
applied["resolution"] = [new_width, new_height]
|
|
129
|
+
guard.note(
|
|
130
|
+
"SCENE_COMPILER_DECISION",
|
|
131
|
+
"preview rendered at an override resolution %dx%d" % (new_width, new_height),
|
|
132
|
+
{"resolution": [new_width, new_height]},
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
samples = options.get("samples")
|
|
136
|
+
if samples is not None:
|
|
137
|
+
samples = int(samples)
|
|
138
|
+
if scene.render.engine == "CYCLES":
|
|
139
|
+
scene.cycles.samples = samples
|
|
140
|
+
elif scene.render.engine == "BLENDER_EEVEE":
|
|
141
|
+
scene.eevee.taa_render_samples = samples
|
|
142
|
+
applied["samples"] = samples
|
|
143
|
+
|
|
144
|
+
if scene.render.resolution_percentage != 100:
|
|
145
|
+
guard.note(
|
|
146
|
+
"SCENE_COMPILER_DECISION",
|
|
147
|
+
"checkpoint had resolution_percentage=%d; preview forces 100%% so the image matches the profile"
|
|
148
|
+
% (scene.render.resolution_percentage,),
|
|
149
|
+
)
|
|
150
|
+
scene.render.resolution_percentage = 100
|
|
151
|
+
|
|
152
|
+
return applied
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def checkpoint_profile(scene):
|
|
156
|
+
"""The render profile the checkpoint was compiled with, from its audit record.
|
|
157
|
+
|
|
158
|
+
Read out of the ``deepblend_render_config`` custom property that
|
|
159
|
+
``compile_scene`` stored on the scene. This is why that property exists: an
|
|
160
|
+
override can then be reported as a DIFFERENCE ("compiled for BLENDER_EEVEE,
|
|
161
|
+
rendered with CYCLES") instead of restating the value it just set.
|
|
162
|
+
"""
|
|
163
|
+
try:
|
|
164
|
+
config = scene.get("deepblend_render_config")
|
|
165
|
+
if config:
|
|
166
|
+
return json.loads(config)
|
|
167
|
+
except Exception:
|
|
168
|
+
pass
|
|
169
|
+
return {}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def render_preview(options=None, guard=None, checkpoint=None, output=None, camera_id=None):
|
|
173
|
+
"""Render one preview frame and return a report describing the artifact.
|
|
174
|
+
|
|
175
|
+
``options`` carries optional overrides; ``checkpoint`` is the ``.blend`` to
|
|
176
|
+
open; ``output`` is where the PNG must land.
|
|
177
|
+
"""
|
|
178
|
+
options = options or {}
|
|
179
|
+
if checkpoint is None:
|
|
180
|
+
raise ActionError("REVISION_CHECKPOINT_MISSING", "a preview render needs a checkpoint .blend to open")
|
|
181
|
+
if output is None:
|
|
182
|
+
raise ActionError("BLENDER_SCRIPT_ERROR", "a preview render needs an output path")
|
|
183
|
+
|
|
184
|
+
report_progress("open_checkpoint", 10)
|
|
185
|
+
scene = open_checkpoint(checkpoint)
|
|
186
|
+
|
|
187
|
+
report_progress("select_camera", 25)
|
|
188
|
+
camera, _ = find_camera(scene, camera_id)
|
|
189
|
+
scene.camera = camera
|
|
190
|
+
|
|
191
|
+
report_progress("configure_render", 40)
|
|
192
|
+
overrides = apply_render_overrides(scene, options, guard)
|
|
193
|
+
|
|
194
|
+
frame = options.get("frame")
|
|
195
|
+
if frame is None:
|
|
196
|
+
# The middle of the project's range, not the first frame: a turntable's
|
|
197
|
+
# first frame is the least informative angle of the whole revolution, and
|
|
198
|
+
# a preview exists to show what the scene looks like.
|
|
199
|
+
frame = int((scene.frame_start + scene.frame_end) / 2)
|
|
200
|
+
frame = int(frame)
|
|
201
|
+
scene.frame_set(frame)
|
|
202
|
+
|
|
203
|
+
os.makedirs(os.path.dirname(os.path.abspath(output)), exist_ok=True)
|
|
204
|
+
scene.render.image_settings.file_format = "PNG"
|
|
205
|
+
scene.render.filepath = output
|
|
206
|
+
|
|
207
|
+
report_progress("render", 55, {"engine": scene.render.engine, "frame": frame})
|
|
208
|
+
try:
|
|
209
|
+
bpy.ops.render.render(write_still=True)
|
|
210
|
+
except Exception as exc:
|
|
211
|
+
# EEVEE historically needs a GPU/GL context and can fail under
|
|
212
|
+
# --background. That is a known environmental limitation, so it is
|
|
213
|
+
# reported as an engine problem rather than as a corrupt scene, and the
|
|
214
|
+
# caller can decide to retry with Cycles.
|
|
215
|
+
raise ActionError(
|
|
216
|
+
"BLENDER_ENGINE_UNAVAILABLE",
|
|
217
|
+
'rendering with engine %s failed: %s' % (scene.render.engine, error_text(exc)),
|
|
218
|
+
{"engine": scene.render.engine, "frame": frame},
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if not os.path.isfile(output):
|
|
222
|
+
raise ActionError(
|
|
223
|
+
"RENDER_NO_OUTPUT",
|
|
224
|
+
'engine %s reported success but wrote no file at "%s"' % (scene.render.engine, output),
|
|
225
|
+
{"engine": scene.render.engine, "output": output},
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
width, height = png_dimensions(output) or (scene.render.resolution_x, scene.render.resolution_y)
|
|
229
|
+
|
|
230
|
+
report_progress("render_done", 95)
|
|
231
|
+
return {
|
|
232
|
+
"outputPath": output,
|
|
233
|
+
"bytes": os.path.getsize(output),
|
|
234
|
+
"width": width,
|
|
235
|
+
"height": height,
|
|
236
|
+
"frame": frame,
|
|
237
|
+
"fps": int(scene.render.fps),
|
|
238
|
+
"frameStart": int(scene.frame_start),
|
|
239
|
+
"frameEnd": int(scene.frame_end),
|
|
240
|
+
"engine": scene.render.engine,
|
|
241
|
+
"cameraId": camera.get("deepblend_id") or camera.name,
|
|
242
|
+
"cameraName": camera.name,
|
|
243
|
+
"lens": round(float(camera.data.lens), 6),
|
|
244
|
+
"overrides": overrides,
|
|
245
|
+
"renderConfig": _render_config(scene),
|
|
246
|
+
"objects": describe_objects(),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def png_dimensions(path):
|
|
251
|
+
"""Read width and height straight out of a PNG's IHDR chunk.
|
|
252
|
+
|
|
253
|
+
Blender's in-memory "Render Result" is NOT readable in ``--background``: it
|
|
254
|
+
reports ``size (0,0)`` and ``has_data=False``, and ``scale()`` raises "failed
|
|
255
|
+
to load image buffer". Reading the file that was actually written is both
|
|
256
|
+
simpler and more truthful — the artifact on disk is the thing being
|
|
257
|
+
described, and if it disagrees with the requested resolution the caller should
|
|
258
|
+
see the disagreement rather than the request echoed back.
|
|
259
|
+
"""
|
|
260
|
+
try:
|
|
261
|
+
with open(path, "rb") as stream:
|
|
262
|
+
header = stream.read(33)
|
|
263
|
+
except Exception:
|
|
264
|
+
return None
|
|
265
|
+
if len(header) < 24 or header[:8] != b"\x89PNG\r\n\x1a\n":
|
|
266
|
+
return None
|
|
267
|
+
try:
|
|
268
|
+
width = int.from_bytes(header[16:20], "big")
|
|
269
|
+
height = int.from_bytes(header[20:24], "big")
|
|
270
|
+
except Exception:
|
|
271
|
+
return None
|
|
272
|
+
if width <= 0 or height <= 0:
|
|
273
|
+
return None
|
|
274
|
+
return width, height
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _render_config(scene):
|
|
278
|
+
"""The render settings actually in force when the frame was written."""
|
|
279
|
+
samples = None
|
|
280
|
+
try:
|
|
281
|
+
if scene.render.engine == "CYCLES":
|
|
282
|
+
samples = int(scene.cycles.samples)
|
|
283
|
+
elif scene.render.engine == "BLENDER_EEVEE":
|
|
284
|
+
samples = int(scene.eevee.taa_render_samples)
|
|
285
|
+
except Exception:
|
|
286
|
+
samples = None
|
|
287
|
+
return {
|
|
288
|
+
"engine": scene.render.engine,
|
|
289
|
+
"resolution": [int(scene.render.resolution_x), int(scene.render.resolution_y)],
|
|
290
|
+
"resolutionPercentage": int(scene.render.resolution_percentage),
|
|
291
|
+
"samples": samples,
|
|
292
|
+
"filmTransparent": bool(scene.render.film_transparent),
|
|
293
|
+
"viewTransform": scene.view_settings.view_transform,
|
|
294
|
+
"look": scene.view_settings.look,
|
|
295
|
+
"exposure": round(float(scene.view_settings.exposure), 6),
|
|
296
|
+
"fps": int(scene.render.fps),
|
|
297
|
+
"frameStart": int(scene.frame_start),
|
|
298
|
+
"frameEnd": int(scene.frame_end),
|
|
299
|
+
}
|