@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,402 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """DeepBlend frame-sequence renderer — one process, an explicit frame list.
4
+
5
+ WHAT THIS IS FOR
6
+ ----------------
7
+ M1/M2 render *previews*: one frame, or a handful of sampled views, to look at.
8
+ M3 renders a **deliverable**: every frame of a range, at the final profile's
9
+ resolution and sample count, so the frames can be encoded into the MP4 that
10
+ leaves the studio. Measured cost on this machine for `watch-commercial` at
11
+ 1920x1080 / Cycles / 256 spp is 19.6-41.4 s per frame, so a 450-frame delivery
12
+ is a 3.4 hour process — which is exactly why it must be resumable.
13
+
14
+ WHY THE FRAME LIST IS AN INPUT AND NOT A RANGE
15
+ ----------------------------------------------
16
+ "只渲缺失帧" (render only the missing frames) is an acceptance condition, and the
17
+ only way to make it checkable is for the renderer to render EXACTLY the frames it
18
+ was handed. If this module were given `frameStart..frameEnd` and skipped what it
19
+ found on disk, the skip decision would live in Python where the ledger's
20
+ correctness could not be asserted from outside, and a truncated frame left by a
21
+ killed process would be silently kept. The host derives the missing set from the
22
+ frame ledger and passes it; this module renders that set and reports, per frame,
23
+ the artifact it actually wrote.
24
+
25
+ THE NAMING TRAP (measured, not assumed)
26
+ ---------------------------------------
27
+ `scene.render.frame_path(frame=f)` computes `<dir>/frame_0001.png`, and
28
+ `bpy.ops.render.render(write_still=True)` writes `<dir>/frame_.png` — the
29
+ predicted name is not the written one. Verified on Blender 5.2.1:
30
+
31
+ frame_path(frame=1) -> /tmp/.../frame_0001.png written: frame_.png
32
+
33
+ So the output path is set EXPLICITLY, with its extension, before every frame, and
34
+ then verified on disk. A renderer that trusted `frame_path` would write every
35
+ frame over the same file and still report success.
36
+
37
+ THE DUPLICATE-EXTENSION TRAP
38
+ ----------------------------
39
+ Setting `filepath` to a name that already ends in `.png` while
40
+ `use_file_extension` is true could plausibly produce `frame_0001.png.png`. It does
41
+ not (measured: the listing after two frames is exactly `frame_0001.png`,
42
+ `frame_0042.png`), and the module still verifies the exact expected path rather
43
+ than trusting that.
44
+
45
+ Runs inside Blender's embedded interpreter.
46
+ """
47
+
48
+ import json
49
+ import os
50
+ import time
51
+
52
+ import bpy
53
+
54
+ from deepblend_render import apply_render_overrides, find_camera, open_checkpoint, png_dimensions
55
+ from deepblend_util import (
56
+ ActionError,
57
+ Guard,
58
+ error_text,
59
+ frame_file_name,
60
+ frame_path,
61
+ report_progress,
62
+ )
63
+
64
+ #: Bytes every frame must carry. A PNG of any real frame is far larger than this;
65
+ #: the check exists to catch a zero-byte file left by a kill between create and
66
+ #: write, which would otherwise be a "frame that exists".
67
+ MIN_FRAME_BYTES = 512
68
+
69
+ #: Blender's PNG signature. A file that does not start with it is not a frame.
70
+ PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
71
+
72
+
73
+ def append_event(events_path, payload):
74
+ """Append one JSON line to the durable event journal and make it durable.
75
+
76
+ WHY A JOURNAL AND NOT JUST STDOUT
77
+ ---------------------------------
78
+ stdout reaches the host through the subprocess collector, which is an
79
+ in-memory buffer in the host's own process — so it dies with the host. The
80
+ acceptance case is a host that dies mid-render, and the frames on disk are
81
+ the ONLY fact that survives it. This journal is written for the facts disk
82
+ cannot express (which pid is rendering, and when each frame landed), and it is
83
+ flushed and fsynced per line so a kill -9 loses at most the line in flight.
84
+ It is corroboration, never the authority: the frame ledger is re-derived from
85
+ the frames themselves. See `host/lib/frame-ledger.js`.
86
+ """
87
+ if not events_path:
88
+ return
89
+ try:
90
+ line = json.dumps(payload, ensure_ascii=False) + "\n"
91
+ with open(events_path, "a", encoding="utf-8") as stream:
92
+ stream.write(line)
93
+ stream.flush()
94
+ os.fsync(stream.fileno())
95
+ except Exception as exc: # noqa: BLE001 - a journal that breaks a render is worse than none
96
+ from deepblend_util import eprint
97
+
98
+ eprint("could not append to the event journal: %s" % (error_text(exc),))
99
+
100
+
101
+ def apply_profile(scene, profile, guard):
102
+ """Apply a whole SceneSpec render profile, and verify it took effect.
103
+
104
+ WHY VERIFY AND NOT JUST SET
105
+ ---------------------------
106
+ `view_transform` in particular is rejected outright by some Blender builds
107
+ (the compiler already carries a comment to that effect). Assigning it and
108
+ moving on means a delivery rendered in the wrong colour space reports success
109
+ — the M2 lesson that a silently wrong number is worse than a loud failure.
110
+ Every setting that the profile asked for is read back and compared.
111
+
112
+ Returns the settings actually in force, which travel into the result document.
113
+ """
114
+ requested = {
115
+ "engine": profile.get("engine"),
116
+ "resolution": profile.get("resolution"),
117
+ "samples": profile.get("samples"),
118
+ "filmTransparent": profile.get("filmTransparent"),
119
+ }
120
+ overrides = apply_render_overrides(
121
+ scene,
122
+ {
123
+ "engine": requested["engine"],
124
+ "width": (requested["resolution"] or [None, None])[0],
125
+ "height": (requested["resolution"] or [None, None])[1],
126
+ "samples": requested["samples"],
127
+ },
128
+ guard,
129
+ )
130
+
131
+ if requested["filmTransparent"] is not None:
132
+ scene.render.film_transparent = bool(requested["filmTransparent"])
133
+
134
+ color = profile.get("colorManagement") or {}
135
+ view_transform = color.get("viewTransform")
136
+ if view_transform:
137
+ try:
138
+ scene.view_settings.view_transform = view_transform
139
+ except Exception as exc:
140
+ raise ActionError(
141
+ "BLENDER_ENGINE_UNAVAILABLE",
142
+ 'the render profile asks for view transform "%s", which this Blender build refuses: %s'
143
+ % (view_transform, error_text(exc)),
144
+ {"requested": view_transform},
145
+ )
146
+ if scene.view_settings.view_transform != view_transform:
147
+ raise ActionError(
148
+ "BLENDER_ENGINE_UNAVAILABLE",
149
+ 'the render profile asks for view transform "%s" but Blender kept "%s"'
150
+ % (view_transform, scene.view_settings.view_transform),
151
+ {"requested": view_transform, "applied": scene.view_settings.view_transform},
152
+ )
153
+ look = color.get("look")
154
+ if look:
155
+ try:
156
+ scene.view_settings.look = look
157
+ except Exception:
158
+ guard.warn(
159
+ "SCENE_COMPILER_DECISION",
160
+ 'the render profile asks for look "%s", which this Blender build does not offer' % (look,),
161
+ {"requested": look},
162
+ )
163
+ exposure = color.get("exposure")
164
+ if exposure is not None:
165
+ scene.view_settings.exposure = float(exposure)
166
+
167
+ if scene.render.resolution_percentage != 100:
168
+ scene.render.resolution_percentage = 100
169
+
170
+ scene.render.image_settings.file_format = "PNG"
171
+ # 8-bit RGB is what an H.264 delivery consumes; a 16-bit or RGBA frame would
172
+ # triple the bytes on a 13 GiB disk for no visible difference in the MP4.
173
+ try:
174
+ scene.render.image_settings.color_mode = "RGB"
175
+ scene.render.image_settings.color_depth = "8"
176
+ except Exception:
177
+ pass
178
+ # Never let a re-render of an existing frame leave a `.blend1`-style backup or
179
+ # a stale file the ledger would then accept.
180
+ scene.render.use_overwrite = True
181
+
182
+ actual = render_config(scene)
183
+ if requested["resolution"] is not None and actual["resolution"] != [
184
+ int(requested["resolution"][0]),
185
+ int(requested["resolution"][1]),
186
+ ]:
187
+ raise ActionError(
188
+ "RENDER_PROFILE_MISSING",
189
+ "the render profile asks for %sx%s but the scene is set to %sx%s"
190
+ % (
191
+ requested["resolution"][0], requested["resolution"][1],
192
+ actual["resolution"][0], actual["resolution"][1],
193
+ ),
194
+ {"requested": requested["resolution"], "applied": actual["resolution"]},
195
+ )
196
+ return overrides, actual
197
+
198
+
199
+ def render_config(scene):
200
+ """The render settings actually in force, for the job's audit record."""
201
+ samples = None
202
+ try:
203
+ if scene.render.engine == "CYCLES":
204
+ samples = int(scene.cycles.samples)
205
+ elif scene.render.engine == "BLENDER_EEVEE":
206
+ samples = int(scene.eevee.taa_render_samples)
207
+ except Exception:
208
+ samples = None
209
+ return {
210
+ "engine": scene.render.engine,
211
+ "resolution": [int(scene.render.resolution_x), int(scene.render.resolution_y)],
212
+ "resolutionPercentage": int(scene.render.resolution_percentage),
213
+ "samples": samples,
214
+ "filmTransparent": bool(scene.render.film_transparent),
215
+ "viewTransform": scene.view_settings.view_transform,
216
+ "look": scene.view_settings.look,
217
+ "exposure": round(float(scene.view_settings.exposure), 6),
218
+ "fps": int(scene.render.fps),
219
+ "fpsBase": int(getattr(scene.render, "fps_base", 1) or 1),
220
+ "frameStart": int(scene.frame_start),
221
+ "frameEnd": int(scene.frame_end),
222
+ }
223
+
224
+
225
+ def verify_frame(path, expected_width, expected_height):
226
+ """Prove one written frame is a complete PNG of the expected size.
227
+
228
+ Three facts, all read off the artifact rather than off the render call:
229
+ the PNG signature, the IHDR dimensions, and a size floor. Blender reporting
230
+ success is not evidence — the ledger is built from files, so the file has to
231
+ be the thing that is checked.
232
+ """
233
+ if not os.path.isfile(path):
234
+ return {"ok": False, "reason": "missing"}
235
+ size = os.path.getsize(path)
236
+ if size < MIN_FRAME_BYTES:
237
+ return {"ok": False, "reason": "truncated", "bytes": size}
238
+ try:
239
+ with open(path, "rb") as stream:
240
+ header = stream.read(33)
241
+ stream.seek(max(0, size - 12))
242
+ tail = stream.read(12)
243
+ except Exception as exc:
244
+ return {"ok": False, "reason": "unreadable: %s" % (error_text(exc),)}
245
+ if len(header) < 24 or header[:8] != PNG_SIGNATURE:
246
+ return {"ok": False, "reason": "not-a-png", "bytes": size}
247
+ width = int.from_bytes(header[16:20], "big")
248
+ height = int.from_bytes(header[20:24], "big")
249
+ if width <= 0 or height <= 0:
250
+ return {"ok": False, "reason": "bad-dimensions", "bytes": size}
251
+ if (expected_width, expected_height) != (None, None) and (width, height) != (expected_width, expected_height):
252
+ return {"ok": False, "reason": "wrong-dimensions", "width": width, "height": height, "bytes": size}
253
+ # A killed process leaves a file with no IEND chunk. This is the cheap
254
+ # structural end-of-file check; full decoding would cost more than the render.
255
+ if tail[-8:-4] != b"IEND":
256
+ return {"ok": False, "reason": "unterminated", "bytes": size}
257
+ return {"ok": True, "bytes": size, "width": width, "height": height}
258
+
259
+
260
+ def render_frames(plan, guard):
261
+ """Render every frame in ``plan`` and return the per-frame artifact report.
262
+
263
+ ``plan`` is a document (not a flag list) for the same reason the view plan is:
264
+ a frame list is a list of records, and N parallel arrays can disagree in
265
+ length. It is validated once, at the top, so a malformed plan is one error
266
+ rather than a half-finished sequence.
267
+ """
268
+ checkpoint = plan.get("checkpoint")
269
+ if not checkpoint:
270
+ raise ActionError("REVISION_CHECKPOINT_MISSING", "render_frames requires a checkpoint .blend")
271
+ frames = plan.get("frames")
272
+ if not isinstance(frames, list) or len(frames) == 0:
273
+ raise ActionError(
274
+ "BLENDER_SCRIPT_ERROR",
275
+ "render_frames requires a non-empty `frames` list; an empty list would report success "
276
+ "while writing nothing",
277
+ )
278
+ try:
279
+ requested = [int(value) for value in frames]
280
+ except (TypeError, ValueError):
281
+ raise ActionError("BLENDER_SCRIPT_ERROR", "every entry of `frames` must be an integer")
282
+
283
+ output_directory = plan.get("outputDirectory")
284
+ if not output_directory:
285
+ raise ActionError("BLENDER_SCRIPT_ERROR", "render_frames requires an output directory")
286
+ prefix = plan.get("filePrefix") or "frame_"
287
+ padding = int(plan.get("padding") or 4)
288
+ events_path = plan.get("events")
289
+
290
+ os.makedirs(output_directory, exist_ok=True)
291
+
292
+ report_progress("open_checkpoint", 2)
293
+ scene = open_checkpoint(checkpoint)
294
+
295
+ camera_id = plan.get("cameraId")
296
+ camera, _ = find_camera(scene, camera_id)
297
+ scene.camera = camera
298
+
299
+ profile = plan.get("profile") or {}
300
+ report_progress("configure_render", 4)
301
+ overrides, actual = apply_profile(scene, profile, guard)
302
+ expected_width = int(actual["resolution"][0])
303
+ expected_height = int(actual["resolution"][1])
304
+
305
+ # The scene's own range is what the encoder must be told, and it belongs to
306
+ # the spec, not to this invocation's frame list. Report it so the host can
307
+ # check "the MP4 covers the project's range" without re-reading the SceneSpec.
308
+ scene_range = [int(scene.frame_start), int(scene.frame_end)]
309
+ planned_range = plan.get("frameRange")
310
+ if isinstance(planned_range, list) and len(planned_range) == 2 and list(planned_range) != scene_range:
311
+ guard.warn(
312
+ "SCENE_COMPILER_DECISION",
313
+ "the render plan covers frames %s but the checkpoint's scene range is %s; the delivery "
314
+ "manifest will record both" % (planned_range, scene_range),
315
+ {"planned": list(planned_range), "scene": scene_range},
316
+ )
317
+
318
+ entries = []
319
+ written = []
320
+ total = len(requested)
321
+ total_bytes = 0
322
+ for index, frame in enumerate(requested):
323
+ percent = 5 + int(90 * index / total)
324
+ report_progress("frame", percent, {"frame": frame, "index": index, "total": total})
325
+ scene.frame_set(frame)
326
+ path = frame_path(output_directory, frame, prefix, padding)
327
+ # Explicit, absolute, WITH the extension — see the naming trap above.
328
+ scene.render.filepath = path
329
+ started = time.time()
330
+ try:
331
+ bpy.ops.render.render(write_still=True)
332
+ except Exception as exc:
333
+ append_event(events_path, {"type": "frame_failed", "frame": frame, "error": error_text(exc)})
334
+ raise ActionError(
335
+ "BLENDER_ENGINE_UNAVAILABLE",
336
+ "rendering frame %d with engine %s failed: %s" % (frame, scene.render.engine, error_text(exc)),
337
+ {"frame": frame, "engine": scene.render.engine},
338
+ )
339
+ elapsed_ms = int((time.time() - started) * 1000)
340
+ verdict = verify_frame(path, expected_width, expected_height)
341
+ if not verdict.get("ok"):
342
+ append_event(events_path, {"type": "frame_failed", "frame": frame, "verify": verdict})
343
+ raise ActionError(
344
+ "RENDER_NO_OUTPUT",
345
+ "frame %d did not produce a complete PNG at %s (%s)"
346
+ % (frame, path, verdict.get("reason")),
347
+ {"frame": frame, "output": path, "verify": verdict},
348
+ )
349
+ entry = {
350
+ "frame": frame,
351
+ "path": path,
352
+ "file": frame_file_name(frame, prefix, padding),
353
+ "bytes": verdict["bytes"],
354
+ "width": verdict["width"],
355
+ "height": verdict["height"],
356
+ "ms": elapsed_ms,
357
+ }
358
+ entries.append(entry)
359
+ written.append(frame)
360
+ total_bytes += verdict["bytes"]
361
+ # Written AFTER the file is verified on disk, so the journal never claims
362
+ # a frame the ledger would call missing.
363
+ append_event(events_path, {"type": "frame", "frame": frame, "bytes": verdict["bytes"], "ms": elapsed_ms})
364
+
365
+ report_progress("frames_done", 97, {"rendered": len(written)})
366
+ append_event(events_path, {"type": "frames_done", "rendered": written})
367
+
368
+ return {
369
+ "renderKind": "frame-sequence",
370
+ "profileName": plan.get("profileName"),
371
+ "checkpoint": checkpoint,
372
+ "cameraId": camera.get("deepblend_id") or camera.name,
373
+ "cameraName": camera.name,
374
+ "outputDirectory": output_directory,
375
+ "filePrefix": prefix,
376
+ "padding": padding,
377
+ "frames": entries,
378
+ "renderedFrames": written,
379
+ "renderedCount": len(written),
380
+ "totalBytes": total_bytes,
381
+ "sceneFrameRange": scene_range,
382
+ "renderConfig": actual,
383
+ "overrides": overrides,
384
+ "requestedProfile": {
385
+ "engine": profile.get("engine"),
386
+ "resolution": profile.get("resolution"),
387
+ "samples": profile.get("samples"),
388
+ },
389
+ }
390
+
391
+
392
+ __all__ = [
393
+ "Guard",
394
+ "MIN_FRAME_BYTES",
395
+ "append_event",
396
+ "frame_file_name",
397
+ "frame_path",
398
+ "png_dimensions",
399
+ "render_config",
400
+ "render_frames",
401
+ "verify_frame",
402
+ ]