@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.
@@ -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
+ }