@deepblend/dsh-blender-provider-local 0.14.0 → 0.16.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 CHANGED
@@ -44,6 +44,7 @@ import {
44
44
  BlenderWarningCode,
45
45
  assertKnownConfigKeys,
46
46
  managedBlenderCandidates,
47
+ normalizeShotCameraSelection,
47
48
  resolveWorkspaceRoot,
48
49
  warning,
49
50
  } from '@deepblend/dsh-blender-contracts'
@@ -55,6 +56,26 @@ export const BLENDER_RUNTIME_SERVICE = 'blenderRuntime'
55
56
  const DEFAULT_MAX_OUTPUT_BYTES = 1024 * 1024
56
57
  /** Default cap for the spill file that holds the complete stream. */
57
58
  const DEFAULT_MAX_SPILL_BYTES = 64 * 1024 * 1024
59
+ /** Cumulative decoded scene images; individual image limits remain separate. */
60
+ const DEFAULT_SCENE_IMAGE_MIB = 1024
61
+
62
+ /**
63
+ * Build one scrubbed environment without requiring private receiver methods.
64
+ * @param {{ maxSceneImageMiB?: number }} config
65
+ * @param {unknown} jobId
66
+ * @returns {Record<string, string>}
67
+ */
68
+ function bootstrapEnvironment(config, jobId) {
69
+ return {
70
+ PATH: process.env.PATH ?? '',
71
+ HOME: process.env.HOME ?? '',
72
+ TMPDIR: process.env.TMPDIR ?? '',
73
+ PYTHONUNBUFFERED: '1',
74
+ PYTHONDONTWRITEBYTECODE: '1',
75
+ DEEPBLEND_JOB_ID: String(jobId ?? ''),
76
+ DEEPBLEND_SCENE_IMAGE_BUDGET_MIB: String(config.maxSceneImageMiB ?? DEFAULT_SCENE_IMAGE_MIB),
77
+ }
78
+ }
58
79
 
59
80
  /** Grace period between SIGTERM and SIGKILL when terminating Blender. */
60
81
  const TERMINATE_GRACE_MS = 10_000
@@ -140,6 +161,8 @@ export const ProviderConfig = z.object({
140
161
  maxOutputBytes: z.number().default(DEFAULT_MAX_OUTPUT_BYTES),
141
162
  /** Cap on the spill file holding the complete stream. */
142
163
  maxSpillBytes: z.number().default(DEFAULT_MAX_SPILL_BYTES),
164
+ /** Pre-build decoded-image estimate in MiB; scene/tool arguments cannot raise it. */
165
+ maxSceneImageMiB: z.number().min(16).max(8192).step(1).default(DEFAULT_SCENE_IMAGE_MIB),
143
166
  /** Extra directories added to the executable allowlist. */
144
167
  executableAllowlist: z.array(z.string()).default([]),
145
168
  /** How long a capabilities result may be reused before re-probing. */
@@ -614,6 +637,10 @@ export default class LocalBlenderRuntime extends Service {
614
637
  // See the host's constructor: a key this row does not read is a startup error, because the schema
615
638
  // accepts it silently (SPEC §17's nested groups versus this package's flat keys).
616
639
  assertKnownConfigKeys(ProviderConfig, config, 'deepblend-blender-runtime')
640
+ const imageMiB = config.maxSceneImageMiB ?? DEFAULT_SCENE_IMAGE_MIB
641
+ if (!Number.isInteger(imageMiB) || imageMiB < 16 || imageMiB > 8192) {
642
+ throw new Error('maxSceneImageMiB must be an integer from 16 through 8192 MiB.')
643
+ }
617
644
  // A CONFIGURED ACTION THAT CANNOT BE ROUTED IS REFUSED AT STARTUP, not ignored. Naming a render
618
645
  // here would leave the operator believing their renders now run in their own Blender, while the
619
646
  // render quietly kept its own process — the same shape as a key this row does not read, which the
@@ -623,7 +650,7 @@ export default class LocalBlenderRuntime extends Service {
623
650
  throw new Error(
624
651
  `sessionActions names ${unroutable.map(action => `"${action}"`).join(', ')}, and this provider can only ` +
625
652
  `serve ${SESSION_ROUTABLE_ACTIONS.join(', ')} from a kept session. The renders that must be cancellable ` +
626
- '(`render_frames`) spawn their own process on purpose: cancelling one kills exactly that render, and a ' +
653
+ '(`render_frames`, `render_shot_frames`) spawn their own process on purpose: cancelling one kills exactly that render, and a ' +
627
654
  'request inside a kept session cannot be killed without ending the session — which, attached, is the ' +
628
655
  'user\'s own Blender.',
629
656
  )
@@ -859,6 +886,7 @@ export default class LocalBlenderRuntime extends Service {
859
886
  }
860
887
  }
861
888
 
889
+
862
890
  /**
863
891
  * Run one bootstrap action end-to-end.
864
892
  *
@@ -902,6 +930,7 @@ export default class LocalBlenderRuntime extends Service {
902
930
  * @param {{ cwd?: string, args?: string[], signal?: AbortSignal }} [options]
903
931
  * @returns {Promise<{ pid: number|null, run: (request: object, options?: object) => Promise<object>, close: () => Promise<void> }>}
904
932
  */
933
+
905
934
  async openSession(options = {}) {
906
935
  if (!existsSync(this.bootstrapPath)) {
907
936
  throw new BlenderError(
@@ -945,6 +974,7 @@ export default class LocalBlenderRuntime extends Service {
945
974
  },
946
975
  graceMs: TERMINATE_GRACE_MS,
947
976
  signal: options.signal,
977
+ env: bootstrapEnvironment(this.config, jobId),
948
978
  })
949
979
 
950
980
  const session = new BlenderSession({
@@ -975,6 +1005,11 @@ export default class LocalBlenderRuntime extends Service {
975
1005
  * @returns {Promise<{ pid: number|null, run: Function, close: Function }>}
976
1006
  */
977
1007
  async attachSession(socketPath, options = {}) {
1008
+ if ((this.config.maxSceneImageMiB ?? DEFAULT_SCENE_IMAGE_MIB) !== DEFAULT_SCENE_IMAGE_MIB) {
1009
+ throw new BlenderError(BlenderErrorCode.UNSUPPORTED_ACTION,
1010
+ 'A custom maxSceneImageMiB requires a managed Blender process. Clear sessionSocket, ' +
1011
+ 'or use the default 1024 MiB when attaching to an existing Blender.')
1012
+ }
978
1013
  if (typeof socketPath !== 'string' || socketPath.length === 0) {
979
1014
  throw new BlenderError(
980
1015
  BlenderErrorCode.RUNTIME_UNAVAILABLE,
@@ -1207,17 +1242,9 @@ export default class LocalBlenderRuntime extends Service {
1207
1242
  },
1208
1243
  graceMs: TERMINATE_GRACE_MS,
1209
1244
  signal: composedSignal,
1210
- env: {
1211
- // Scrub anything that could carry a secret into the child (SPEC §15.5:
1212
- // the DeepSeek API key must never reach the Blender subprocess).
1213
- PATH: process.env.PATH ?? '',
1214
- HOME: process.env.HOME ?? '',
1215
- TMPDIR: process.env.TMPDIR ?? '',
1216
- // Deterministic, unbuffered Python so partial output is never lost.
1217
- PYTHONUNBUFFERED: '1',
1218
- PYTHONDONTWRITEBYTECODE: '1',
1219
- DEEPBLEND_JOB_ID: correlationId,
1220
- },
1245
+ // The operator budget and scrubbed environment apply to every owned
1246
+ // process; caller payloads and inherited environment cannot raise it.
1247
+ env: bootstrapEnvironment(this.config, correlationId),
1221
1248
  })
1222
1249
  } catch (cause) {
1223
1250
  clearTimeout(timer)
@@ -1587,6 +1614,8 @@ export default class LocalBlenderRuntime extends Service {
1587
1614
  assertDigest(await digest(snapshot))
1588
1615
  const run = await this.runBootstrap({ action: 'inspect_geometry', jobId: request.jobId,
1589
1616
  payload: { entityIds: request.entityIds, frame: request.frame, checkpointSha256: expected,
1617
+ ...(request.assetInstanceIds === undefined ? {} : { assetInstanceIds: request.assetInstanceIds }),
1618
+ ...(request.cameraId === undefined ? {} : { cameraId: request.cameraId, cameraMargin: request.cameraMargin ?? .03 }),
1590
1619
  ...(request.objectAxes === true ? { objectAxes: true } : {}),
1591
1620
  ...(request.surfaceSamples === undefined ? {} : { surfaceSamples: request.surfaceSamples }) } },
1592
1621
  { signal: request.signal, session: false, args: ['--blend', snapshot] })
@@ -1597,6 +1626,7 @@ export default class LocalBlenderRuntime extends Service {
1597
1626
  if (request.objectAxes === true && run.envelope?.result?.schemaVersion !== 'deepblend.geometry-inspection/v3') {
1598
1627
  throw new BlenderError(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'The Blender bootstrap does not support object-local native bounds. Upgrade DeepBlend; ordinary reads remain available.')
1599
1628
  }
1629
+ if ((request.assetInstanceIds !== undefined || request.cameraId !== undefined) && run.envelope?.result?.schemaVersion !== 'deepblend.geometry-inspection/v4') throw new BlenderError(BlenderErrorCode.RUNTIME_UNAVAILABLE, 'The Blender bootstrap does not support aggregate asset bounds; upgrade DeepBlend.')
1600
1630
  const report = verifyGeometryReport(run.envelope?.result, request)
1601
1631
  assertDigest(await digest(source))
1602
1632
  aborted()
@@ -1730,6 +1760,18 @@ export default class LocalBlenderRuntime extends Service {
1730
1760
  * processPath: string, argv: string[], startedAt: number, executable: string}>}
1731
1761
  */
1732
1762
  async startFrameSequence(request) {
1763
+ return LocalBlenderRuntime.prototype._startFrameSequence.call(this, request, { action: 'render_frames' })
1764
+ }
1765
+
1766
+ /** Explicit opt-in; an older bootstrap refuses the distinct action before rendering. */
1767
+ async startShotFrameSequence(request) {
1768
+ if (request?.cameraId != null) throw new BlenderError(BlenderErrorCode.SCENE_SPEC_INVALID,
1769
+ 'A shot-camera sequence cannot also select one camera.')
1770
+ const cameraSelection = normalizeShotCameraSelection(request?.cameraSelection, request?.frames)
1771
+ return LocalBlenderRuntime.prototype._startFrameSequence.call(this, request, { action: 'render_shot_frames', cameraSelection })
1772
+ }
1773
+
1774
+ async _startFrameSequence(request, selection) {
1733
1775
  if (typeof request?.checkpointPath !== 'string' || request.checkpointPath.length === 0) {
1734
1776
  throw new BlenderError(
1735
1777
  BlenderErrorCode.REVISION_CHECKPOINT_MISSING,
@@ -1808,6 +1850,7 @@ export default class LocalBlenderRuntime extends Service {
1808
1850
  attemptToken: request.attemptToken ?? null,
1809
1851
  checkpoint: request.checkpointPath,
1810
1852
  cameraId: request.cameraId ?? null,
1853
+ ...(selection.cameraSelection === undefined ? {} : { cameraSelection: selection.cameraSelection }),
1811
1854
  profileName: request.profileName ?? null,
1812
1855
  profile: request.profile ?? {},
1813
1856
  frameRange: request.frameRange ?? null,
@@ -1820,7 +1863,7 @@ export default class LocalBlenderRuntime extends Service {
1820
1863
  writeFileSync(requestPath, JSON.stringify({
1821
1864
  protocolVersion: BLENDER_PROTOCOL_VERSION,
1822
1865
  jobId: request.jobId ?? null,
1823
- action: 'render_frames',
1866
+ action: selection.action,
1824
1867
  }, null, 2), 'utf8')
1825
1868
 
1826
1869
  const argv = [
@@ -1859,14 +1902,7 @@ export default class LocalBlenderRuntime extends Service {
1859
1902
  // timeout indistinguishable from a caller's cancellation in the record.
1860
1903
  // Cancellation goes through `handle.terminate()`, which the host calls
1861
1904
  // from `cancelJob`.
1862
- env: {
1863
- PATH: process.env.PATH ?? '',
1864
- HOME: process.env.HOME ?? '',
1865
- TMPDIR: process.env.TMPDIR ?? '',
1866
- PYTHONUNBUFFERED: '1',
1867
- PYTHONDONTWRITEBYTECODE: '1',
1868
- DEEPBLEND_JOB_ID: String(request.jobId ?? ''),
1869
- },
1905
+ env: bootstrapEnvironment(this.config, request.jobId),
1870
1906
  })
1871
1907
  } catch (cause) {
1872
1908
  throw new BlenderError(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-provider-local",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "type": "module",
5
5
  "description": "Local Blender Provider: the BlenderRuntime Service over ctx.subprocess (batch execution).",
6
6
  "main": "lib/index.js",
@@ -11,7 +11,7 @@
11
11
  "./package.json": "./package.json"
12
12
  },
13
13
  "dependencies": {
14
- "@deepblend/dsh-blender-contracts": "0.14.0"
14
+ "@deepblend/dsh-blender-contracts": "0.16.0"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {
@@ -33,6 +33,8 @@ ACTIONS (M3)
33
33
  at a named SceneSpec render profile, into a persistent
34
34
  directory. Long-running and resumable: the host passes only
35
35
  the frames the frame ledger says are missing.
36
+ ``render_shot_frames`` the same isolated process boundary, with a strict saved-shot
37
+ selection and actual per-frame camera/PNG attribution.
36
38
 
37
39
  ACTIONS (M2)
38
40
  ------------
@@ -103,6 +105,7 @@ SUPPORTED_ACTIONS = (
103
105
  "render_preview",
104
106
  "render_views",
105
107
  "render_frames",
108
+ "render_shot_frames",
106
109
  )
107
110
 
108
111
  # Process exit codes. 0 is reserved for a successfully written success envelope.
@@ -370,7 +373,7 @@ def action_render_views(request, options):
370
373
  return payload, guard.warnings, guard.notices
371
374
 
372
375
 
373
- def action_render_frames(request, options):
376
+ def action_render_frames(request, options, require_camera_selection=False):
374
377
  """Render an explicit frame list at a named render profile (M3).
375
378
 
376
379
  The plan travels as a FILE for the same reason the view plan does, and the
@@ -408,10 +411,14 @@ def action_render_frames(request, options):
408
411
  plan["events"] = options.get("events") or plan.get("events")
409
412
 
410
413
  guard = Guard()
411
- payload = render_frames(plan, guard)
414
+ payload = render_frames(plan, guard, require_camera_selection=require_camera_selection)
412
415
  return payload, guard.warnings, guard.notices
413
416
 
414
417
 
418
+ def action_render_shot_frames(request, options):
419
+ return action_render_frames(request, options, require_camera_selection=True)
420
+
421
+
415
422
  def write_process_identity(path, job_id, action, attempt_token=None):
416
423
  """Record this process's own pid, atomically, before any Blender work.
417
424
 
@@ -449,11 +456,11 @@ def write_process_identity(path, job_id, action, attempt_token=None):
449
456
  def _attempt_token_from_plan(action, options):
450
457
  """The attempt token, when the action's input document carries one.
451
458
 
452
- Only `render_frames` has a plan file; every other action answers None, and the
459
+ Both frame actions have a plan file; every other action answers None, and the
453
460
  identity document simply records no token. Read here rather than threaded
454
461
  through every action because the token belongs to the PROCESS, not to the work.
455
462
  """
456
- if action != "render_frames":
463
+ if action not in ("render_frames", "render_shot_frames"):
457
464
  return None
458
465
  plan_path = options.get("frames")
459
466
  if not plan_path or not os.path.isfile(plan_path):
@@ -492,6 +499,7 @@ ACTIONS = {
492
499
  "render_preview": action_render_preview,
493
500
  "render_views": action_render_views,
494
501
  "render_frames": action_render_frames,
502
+ "render_shot_frames": action_render_shot_frames,
495
503
  }
496
504
 
497
505
 
@@ -48,6 +48,7 @@ Runs inside Blender's embedded interpreter.
48
48
  import hashlib
49
49
  import json
50
50
  import os
51
+ import re
51
52
  import time
52
53
 
53
54
  import bpy
@@ -258,7 +259,55 @@ def verify_frame(path, expected_width, expected_height):
258
259
  return {"ok": True, "bytes": size, "width": width, "height": height}
259
260
 
260
261
 
261
- def render_frames(plan, guard):
262
+ def shot_camera_selection(scene, plan, requested, required):
263
+ value = plan.get("cameraSelection")
264
+ if not required:
265
+ if value is not None:
266
+ raise ActionError("SCENE_SPEC_INVALID", "Shot camera selection requires render_shot_frames; the legacy render action never guesses cuts.")
267
+ return None, {}
268
+ fail = lambda message: ActionError("SCENE_SPEC_INVALID", "Shot camera selection: " + message)
269
+ if (not isinstance(value, dict) or set(value) != {"schemaVersion", "mode", "shots"}
270
+ or value.get("schemaVersion") != "deepblend.shot-camera-selection/v1" or value.get("mode") != "shots"
271
+ or not isinstance(value.get("shots"), list) or not 1 <= len(value["shots"]) <= 64
272
+ or plan.get("cameraId") is not None):
273
+ raise fail("an explicit bounded saved-shot selection without a single-camera override is required.")
274
+ if any(isinstance(frame, bool) or not isinstance(frame, int) or abs(frame) > 9007199254740991 for frame in plan.get("frames", [])):
275
+ raise fail("requested frames must be safe integers.")
276
+ shots, seen = [], set()
277
+ for shot in value["shots"]:
278
+ if not isinstance(shot, dict) or set(shot) != {"id", "cameraId", "frameRange"}:
279
+ raise fail("each shot needs exactly id, cameraId and frameRange.")
280
+ for key in ("id", "cameraId"):
281
+ if not isinstance(shot[key], str) or not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9._-]{0,127}", shot[key]):
282
+ raise fail("shot and camera IDs must be saved identifiers.")
283
+ bounds = shot["frameRange"]
284
+ if (shot["id"] in seen or not isinstance(bounds, list) or len(bounds) != 2
285
+ or any(isinstance(v, bool) or not isinstance(v, int) or abs(v) > 9007199254740991 for v in bounds)
286
+ or bounds[0] > bounds[1]):
287
+ raise fail("distinct shots need ordered integer frame ranges.")
288
+ seen.add(shot["id"])
289
+ shots.append({"id": shot["id"], "cameraId": shot["cameraId"], "frameRange": list(bounds)})
290
+ shots.sort(key=lambda shot: (shot["frameRange"][0], shot["id"]))
291
+ for before, after in zip(shots, shots[1:]):
292
+ if after["frameRange"][0] <= before["frameRange"][1]:
293
+ raise fail("saved shot ranges overlap.")
294
+ selection = {"schemaVersion": "deepblend.shot-camera-selection/v1", "mode": "shots", "shots": shots}
295
+ if len(json.dumps(selection, separators=(",", ":")).encode("utf-8")) > 8192:
296
+ raise fail("selection exceeds its 8192-byte metadata budget.")
297
+ # Resolve every named native camera before writing any frame. An absent
298
+ # camera is a refusal, never a fallback to the checkpoint's active camera.
299
+ cameras = {shot["cameraId"]: find_camera(scene, shot["cameraId"])[0] for shot in shots}
300
+ selected = {}
301
+ for frame in requested:
302
+ matches = [shot for shot in shots if shot["frameRange"][0] <= frame <= shot["frameRange"][1]]
303
+ if len(matches) != 1:
304
+ raise fail("frame %d must belong to exactly one saved shot." % frame)
305
+ shot = matches[0]
306
+ selected[frame] = (cameras[shot["cameraId"]], shot["id"], shot["cameraId"])
307
+ return selection, selected
308
+
309
+
310
+ def render_frames(plan, guard, require_camera_selection=False):
262
311
  """Render every frame in ``plan`` and return the per-frame artifact report.
263
312
 
264
313
  ``plan`` is a document (not a flag list) for the same reason the view plan is:
@@ -293,9 +342,11 @@ def render_frames(plan, guard):
293
342
  report_progress("open_checkpoint", 2)
294
343
  scene = open_checkpoint(checkpoint)
295
344
 
296
- camera_id = plan.get("cameraId")
297
- camera, _ = find_camera(scene, camera_id)
298
- scene.camera = camera
345
+ camera_selection, selected_cameras = shot_camera_selection(scene, plan, requested, require_camera_selection)
346
+ camera = None
347
+ if camera_selection is None:
348
+ camera, _ = find_camera(scene, plan.get("cameraId"))
349
+ scene.camera = camera
299
350
 
300
351
  profile = plan.get("profile") or {}
301
352
  report_progress("configure_render", 4)
@@ -328,6 +379,11 @@ def render_frames(plan, guard):
328
379
  percent = 5 + int(90 * index / total)
329
380
  report_progress("frame", percent, {"frame": frame, "index": index, "total": total})
330
381
  scene.frame_set(frame)
382
+ if camera_selection is not None:
383
+ camera, shot_id, selected_id = selected_cameras[frame]
384
+ # Apply AFTER native animation/marker evaluation; no native file is
385
+ # saved and the legacy single-camera route remains unchanged.
386
+ scene.camera = camera
331
387
  path = frame_path(output_directory, frame, prefix, padding)
332
388
  # Explicit, absolute, WITH the extension — see the naming trap above.
333
389
  scene.render.filepath = path
@@ -342,6 +398,13 @@ def render_frames(plan, guard):
342
398
  {"frame": frame, "engine": scene.render.engine},
343
399
  )
344
400
  elapsed_ms = int((time.time() - started) * 1000)
401
+ camera_evidence = {}
402
+ if camera_selection is not None:
403
+ actual_id = scene.camera.get("deepblend_id") if scene.camera is not None else None
404
+ if actual_id != selected_id:
405
+ append_event(events_path, {"type": "frame_failed", "frame": frame, "expectedCameraId": selected_id, "actualCameraId": actual_id})
406
+ raise ActionError("RENDER_NO_OUTPUT", "Frame %d did not retain its selected shot camera; no camera attribution was accepted." % frame)
407
+ camera_evidence = {"cameraId": actual_id, "shotId": shot_id}
345
408
  verdict = verify_frame(path, expected_width, expected_height)
346
409
  if not verdict.get("ok"):
347
410
  append_event(events_path, {"type": "frame_failed", "frame": frame, "verify": verdict})
@@ -359,6 +422,7 @@ def render_frames(plan, guard):
359
422
  "width": verdict["width"],
360
423
  "height": verdict["height"],
361
424
  "ms": elapsed_ms,
425
+ **camera_evidence,
362
426
  }
363
427
  entries.append(entry)
364
428
  written.append(frame)
@@ -370,7 +434,7 @@ def render_frames(plan, guard):
370
434
  for chunk in iter(lambda: stream.read(256 * 1024), b""):
371
435
  digest.update(chunk)
372
436
  append_event(events_path, {"type": "frame", "frame": frame, "bytes": verdict["bytes"], "ms": elapsed_ms,
373
- "attemptToken": attempt_token, "sha256": digest.hexdigest()})
437
+ "attemptToken": attempt_token, "sha256": digest.hexdigest(), **camera_evidence})
374
438
 
375
439
  report_progress("frames_done", 97, {"rendered": len(written)})
376
440
  append_event(events_path, {"type": "frames_done", "rendered": written})
@@ -379,8 +443,9 @@ def render_frames(plan, guard):
379
443
  "renderKind": "frame-sequence",
380
444
  "profileName": plan.get("profileName"),
381
445
  "checkpoint": checkpoint,
382
- "cameraId": camera.get("deepblend_id") or camera.name,
383
- "cameraName": camera.name,
446
+ "cameraId": (camera.get("deepblend_id") or camera.name) if camera_selection is None else None,
447
+ "cameraName": camera.name if camera_selection is None else None,
448
+ **({"cameraSelection": camera_selection} if camera_selection is not None else {}),
384
449
  "outputDirectory": output_directory,
385
450
  "filePrefix": prefix,
386
451
  "padding": padding,
@@ -0,0 +1,68 @@
1
+ """Read-only perspective projection of actual evaluated vertices at one frame."""
2
+ import math
3
+ import bpy
4
+ from deepblend_util import ActionError
5
+
6
+
7
+ def framing_camera(scene, graph, camera_id, margin):
8
+ # A full-camera frustum cannot certify a masked/cropped render region or
9
+ # multiple eye views. Keep manual settings intact and refuse that claim.
10
+ if scene.render.use_border or scene.render.use_multiview:
11
+ raise ActionError('BLENDER_UNSUPPORTED_ACTION', 'Native camera framing does not support render borders or multiview; manual settings are unchanged. Geometry inspection without a camera remains available.')
12
+ obj = bpy.data.objects.get('db_camera__' + camera_id)
13
+ if obj is None or obj.type != 'CAMERA':
14
+ raise ActionError('SCENE_SPEC_INVALID', 'Selected native framing camera does not exist.')
15
+ camera = obj.evaluated_get(graph)
16
+ if camera.data.type != 'PERSP':
17
+ raise ActionError('BLENDER_UNSUPPORTED_ACTION', 'Native vertex framing currently requires a perspective camera.')
18
+ # Blender's camera projection ignores object scale (world_to_camera_view
19
+ # normalizes the matrix too). Respect old projects' manual camera scale.
20
+ normalized = camera.matrix_world.normalized()
21
+ if not math.isfinite(normalized.determinant()) or abs(normalized.determinant()) < 1e-12:
22
+ raise ActionError('SCENE_VALIDATION_FAILED', 'Degenerate native camera transform.')
23
+ inverse = normalized.inverted()
24
+ corners = camera.data.view_frame(scene=scene)
25
+ slopes = [(p.x/-p.z, p.y/-p.z) for p in corners]
26
+ limits = [min(p[0] for p in slopes), max(p[0] for p in slopes), min(p[1] for p in slopes), max(p[1] for p in slopes)]
27
+ if not all(math.isfinite(v) for v in limits) or limits[0] >= limits[1] or limits[2] >= limits[3]:
28
+ raise ActionError('SCENE_VALIDATION_FAILED', 'Invalid native camera frustum.')
29
+ return inverse, limits, {'id': camera_id, 'type': 'PERSP', 'margin': margin,
30
+ 'clipStartMeters': float(camera.data.clip_start), 'clipEndMeters': float(camera.data.clip_end)}
31
+
32
+
33
+ def project_geometry(obj, aggregate, graph, calibration):
34
+ inverse, (left, right, bottom, top), camera = calibration
35
+ parts = ([obj] + list(obj.children_recursive)) if aggregate else [obj]
36
+ meshes = [p for p in parts if p.type == 'MESH']
37
+ if any(p.hide_render for p in parts):
38
+ return {'status': 'unavailable-render-hidden'}
39
+ low, high, nearest, farthest, count = [math.inf]*2, [-math.inf]*2, math.inf, -math.inf, 0
40
+ for part in meshes:
41
+ evaluated = part.evaluated_get(graph)
42
+ mesh = evaluated.to_mesh()
43
+ try:
44
+ count += len(mesh.vertices) if mesh else 0
45
+ if count > 2000000:
46
+ return {'status': 'geometry-budget-exceeded'}
47
+ if mesh is None or not mesh.vertices:
48
+ raise ActionError('SCENE_VALIDATION_FAILED', 'Geometry disappeared during framing inspection.')
49
+ for vertex in mesh.vertices:
50
+ point = inverse @ (evaluated.matrix_world @ vertex.co)
51
+ depth = -float(point.z)
52
+ if not all(math.isfinite(v) for v in point):
53
+ raise ActionError('SCENE_VALIDATION_FAILED', 'Non-finite native framing vertex.')
54
+ nearest, farthest = min(nearest, depth), max(farthest, depth)
55
+ if depth > 1e-8:
56
+ uv = [(float(point.x)/depth-left)/(right-left), (float(point.y)/depth-bottom)/(top-bottom)]
57
+ for axis in range(2):
58
+ low[axis], high[axis] = min(low[axis], uv[axis]), max(high[axis], uv[axis])
59
+ finally:
60
+ evaluated.to_mesh_clear()
61
+ result = {'status': 'not-fully-in-front' if nearest <= 1e-8 else 'measured',
62
+ 'nearestDepthMeters': nearest, 'farthestDepthMeters': farthest,
63
+ 'containedInClip': nearest >= camera['clipStartMeters'] and farthest <= camera['clipEndMeters']}
64
+ if result['status'] == 'measured':
65
+ margin = camera['margin']
66
+ result.update(normalizedBounds={'min': low, 'max': high},
67
+ containedInFrame=all(low[i] >= margin and high[i] <= 1-margin for i in range(2)))
68
+ return result
@@ -11,6 +11,18 @@ SOCKETS = {'baseColor': 'Base Color', 'roughness': 'Roughness', 'metallic': 'Met
11
11
  'normal': 'Normal', 'alpha': 'Alpha', 'emissionColor': 'Emission Color'}
12
12
 
13
13
 
14
+ def scene_image_budget_bytes():
15
+ """Read the managed process's operator limit; scene data cannot raise it.
16
+
17
+ This changes only the cumulative scene allocation. Individual image, header,
18
+ format and per-import limits retain their existing resource boundaries.
19
+ """
20
+ value = os.environ.get('DEEPBLEND_SCENE_IMAGE_BUDGET_MIB', str(MAX_IMAGE_DECODE_BYTES // (1024 * 1024)))
21
+ if not 1 <= len(value) <= 4 or not value.isascii() or not value.isdecimal() or not 16 <= int(value) <= 8192:
22
+ raise ActionError('SCENE_SPEC_INVALID', 'operator scene image budget must be an integer from 16 through 8192 MiB')
23
+ return int(value) * 1024 * 1024
24
+
25
+
14
26
  def image_asset_path(asset, project_root):
15
27
  root = os.path.realpath(project_root or '.')
16
28
  path = os.path.realpath(os.path.join(root, asset['path']))
@@ -26,6 +38,8 @@ def check_scene_image_budget(spec, project_root):
26
38
  cache of header facts must not collapse their allocation costs. glTF/GLB/OBJ
27
39
  image declarations are conservatively counted per imported instance.
28
40
  """
41
+ limit = scene_image_budget_bytes()
42
+ label = '1 GiB' if limit == MAX_IMAGE_DECODE_BYTES else '%d MiB' % (limit // (1024 * 1024))
29
43
  assets = {asset['id']: asset for asset in spec.get('assets') or []}
30
44
  bindings = []
31
45
  environment = (spec.get('world') or {}).get('environment')
@@ -43,8 +57,8 @@ def check_scene_image_budget(spec, project_root):
43
57
  if path not in facts:
44
58
  facts[path] = check_image_header(path)
45
59
  total += facts[path]['decodedBytes']
46
- if total > MAX_IMAGE_DECODE_BYTES:
47
- raise ActionError('ASSET_CONTENT_MISMATCH', 'declared scene images exceed the 1 GiB decoded pixel budget')
60
+ if total > limit:
61
+ raise ActionError('ASSET_CONTENT_MISMATCH', 'declared scene images exceed the %s decoded pixel budget' % label)
48
62
  imported_images, imported_facts = 0, {}
49
63
  for entity in spec.get('entities') or []:
50
64
  if entity.get('type') != 'asset-instance':
@@ -58,9 +72,9 @@ def check_scene_image_budget(spec, project_root):
58
72
  imported_facts[key] = inspect(project_root, asset)
59
73
  total += imported_facts[key]['decodedBytes']
60
74
  imported_images += imported_facts[key]['images']
61
- if total > MAX_IMAGE_DECODE_BYTES:
62
- raise ActionError('ASSET_CONTENT_MISMATCH', 'declared and imported scene images exceed the 1 GiB decoded pixel budget')
63
- return {'bindings': len(bindings), 'importedImages': imported_images, 'decodedBytes': total}
75
+ if total > limit:
76
+ raise ActionError('ASSET_CONTENT_MISMATCH', 'declared and imported scene images exceed the %s decoded pixel budget' % label)
77
+ return {'bindings': len(bindings), 'importedImages': imported_images, 'decodedBytes': total, 'limitBytes': limit}
64
78
 
65
79
 
66
80
  def pack_imported_material_images(objects):
@@ -29,6 +29,72 @@ def object_axis_frame(matrix):
29
29
  return lengths, basis
30
30
 
31
31
 
32
+ def measure_asset_bounds(obj, graph):
33
+ """Whole imported instance, never a partial/declared or transformed AABB guess."""
34
+ import bpy
35
+ entry = {'type': 'ASSET_MESH_SET', 'available': False}
36
+ def unavailable(reason):
37
+ return {**entry, 'unavailableReason': reason}
38
+ pending, parts, visited = [obj], [], set()
39
+ while pending:
40
+ part = pending.pop()
41
+ if part.as_pointer() in visited or len(visited) >= 4096:
42
+ return unavailable('unsupported-hierarchy')
43
+ visited.add(part.as_pointer())
44
+ if part != obj and part.name.startswith(('db_entity__', 'db_camera__', 'db_light__')):
45
+ # Reused imports carry the owning entity prefix on their parts.
46
+ # Only that explicit ownership/part marker is exempt; another
47
+ # entity root or an unmarked manual object remains unsupported.
48
+ owner = obj.get('deepblend_id')
49
+ own_part = (isinstance(owner, str) and bool(owner)
50
+ and part.name.startswith(obj.name + '_')
51
+ and part.get('deepblend_id') == owner
52
+ and part.get('deepblend_kind') == 'entity'
53
+ and isinstance(part.get('deepblend_part_id'), str)
54
+ and bool(part.get('deepblend_part_id')))
55
+ if not own_part:
56
+ return unavailable('unsupported-hierarchy')
57
+ if part.type not in ('EMPTY', 'MESH') or part.instance_type != 'NONE':
58
+ return unavailable('unsupported-hierarchy')
59
+ if not part.visible_get(view_layer=bpy.context.view_layer):
60
+ return unavailable('viewport-disabled')
61
+ if part.type == 'MESH':
62
+ parts.append(part)
63
+ if len(parts) > 128:
64
+ return unavailable('geometry-budget-exceeded')
65
+ pending.extend(part.children)
66
+ if not parts:
67
+ return unavailable('empty-or-invalid-geometry')
68
+ low, high, vertices, polygons = [math.inf]*3, [-math.inf]*3, 0, 0
69
+ for part in parts:
70
+ evaluated = part.evaluated_get(graph)
71
+ mesh = evaluated.to_mesh()
72
+ try:
73
+ if mesh is None or not mesh.vertices:
74
+ return unavailable('empty-or-invalid-geometry')
75
+ vertices += len(mesh.vertices)
76
+ polygons += len(mesh.polygons)
77
+ if vertices > 2000000 or polygons > 2000000:
78
+ return unavailable('geometry-budget-exceeded')
79
+ for vertex in mesh.vertices:
80
+ point = evaluated.matrix_world @ vertex.co
81
+ if not all(math.isfinite(x) for x in point):
82
+ return unavailable('empty-or-invalid-geometry')
83
+ for axis in range(3):
84
+ low[axis] = min(low[axis], float(point[axis]))
85
+ high[axis] = max(high[axis], float(point[axis]))
86
+ finally:
87
+ evaluated.to_mesh_clear()
88
+ matrix = [[float(x) for x in row] for row in obj.evaluated_get(graph).matrix_world]
89
+ scale = [float(x) for x in obj.scale]
90
+ if not all(math.isfinite(x) for row in matrix for x in row) or not all(math.isfinite(x) for x in scale):
91
+ return unavailable('empty-or-invalid-geometry')
92
+ return {**entry, 'available': True, 'worldBoundsMeters': {'min': low, 'max': high},
93
+ 'worldAxisExtentMm': [(high[i]-low[i])*1000 for i in range(3)],
94
+ 'worldMatrix': matrix, 'scale': scale, 'vertexCount': vertices,
95
+ 'polygonCount': polygons, 'meshObjectCount': len(parts)}
96
+
97
+
32
98
  def inspect_geometry(request, options):
33
99
  import bpy
34
100
  payload = request.get('payload')
@@ -43,6 +109,17 @@ def inspect_geometry(request, options):
43
109
  object_axes = payload.get('objectAxes', False)
44
110
  if type(object_axes) is not bool:
45
111
  raise ActionError('BLENDER_SCRIPT_ERROR', 'objectAxes must be boolean.')
112
+ camera_id = payload.get('cameraId')
113
+ camera_margin = payload.get('cameraMargin', .03)
114
+ if camera_id is not None and (not isinstance(camera_id, str) or re.fullmatch(r'[a-zA-Z][a-zA-Z0-9._-]{0,127}', camera_id) is None or object_axes or 'surfaceSamples' in payload):
115
+ raise ActionError('BLENDER_SCRIPT_ERROR', 'Framing requires one explicit camera without axes or samples.')
116
+ if 'cameraMargin' in payload and camera_id is None or type(camera_margin) not in (int, float) or not math.isfinite(camera_margin) or not 0 <= camera_margin <= .25:
117
+ raise ActionError('BLENDER_SCRIPT_ERROR', 'Framing margin must be finite from 0 to 0.25 and requires cameraId.')
118
+ asset_ids = payload.get('assetInstanceIds')
119
+ if asset_ids is not None and (not isinstance(asset_ids, list) or not asset_ids or
120
+ any(not isinstance(x, str) or x not in ids for x in asset_ids) or len(set(asset_ids)) != len(asset_ids) or
121
+ object_axes or 'surfaceSamples' in payload):
122
+ raise ActionError('BLENDER_SCRIPT_ERROR', 'Asset bounds require distinct selected asset roots without object axes or surface samples.')
46
123
  samples = payload.get('surfaceSamples')
47
124
  if 'surfaceSamples' in payload:
48
125
  from deepblend_inspect_surface import validate_surface_selection
@@ -66,13 +143,19 @@ def inspect_geometry(request, options):
66
143
  if measured_frame != frame:
67
144
  raise ActionError('SCENE_VALIDATION_FAILED', 'Blender did not evaluate the requested inspection frame.')
68
145
  graph = bpy.context.evaluated_depsgraph_get()
146
+ calibration = None
147
+ if camera_id is not None:
148
+ from deepblend_geometry_framing import framing_camera, project_geometry
149
+ calibration = framing_camera(bpy.context.scene, graph, camera_id, camera_margin)
69
150
  objects = []
70
151
  for entity_id in ids:
71
152
  obj = bpy.data.objects.get('db_entity__' + entity_id)
72
153
  if obj is None:
73
154
  raise ActionError('SCENE_SPEC_INVALID', 'The selected native entity does not exist: ' + entity_id)
74
155
  entry = {'entityId': entity_id, 'type': obj.type, 'available': False}
75
- if obj.type != 'MESH':
156
+ if asset_ids is not None and entity_id in asset_ids:
157
+ entry = {'entityId': entity_id, **measure_asset_bounds(obj, graph)}
158
+ elif obj.type != 'MESH':
76
159
  entry['unavailableReason'] = 'not-mesh'
77
160
  elif not obj.visible_get(view_layer=bpy.context.view_layer):
78
161
  entry['unavailableReason'] = 'viewport-disabled'
@@ -114,6 +197,8 @@ def inspect_geometry(request, options):
114
197
  objectAxisExtentMm=[(local_high[i] - local_low[i]) * 1000 for i in range(3)])
115
198
  finally:
116
199
  evaluated.to_mesh_clear()
200
+ if calibration is not None and entry['available']:
201
+ entry['cameraProjection'] = project_geometry(obj, asset_ids is not None and entity_id in asset_ids, graph, calibration)
117
202
  objects.append(entry)
118
203
  report = {'schemaVersion': 'deepblend.geometry-inspection/v1', 'checkpointSha256': expected,
119
204
  'checkpointUnchanged': True, 'frame': measured_frame, 'blenderVersion': bpy.app.version_string,
@@ -125,6 +210,10 @@ def inspect_geometry(request, options):
125
210
  if object_axes:
126
211
  report['schemaVersion'] = 'deepblend.geometry-inspection/v3'
127
212
  report['objectAxisBasis'] = 'evaluated-object-axes-scaled'
213
+ if calibration is not None:
214
+ report['camera'] = calibration[2]
215
+ if asset_ids is not None or calibration is not None:
216
+ report['schemaVersion'] = 'deepblend.geometry-inspection/v4'
128
217
  if file_digest(checkpoint) != expected:
129
218
  raise ActionError('SCENE_VALIDATION_FAILED', 'The checkpoint changed during geometry inspection.')
130
219
  return report, [], []
@@ -51,7 +51,44 @@ def _inside(target, point):
51
51
  return votes >= 2
52
52
 
53
53
 
54
- def _target(entity_id, obj):
54
+ def _world_matrices(objects):
55
+ """Evaluate reference poses without retaining any visibility changes.
56
+
57
+ Viewport-disabled objects can keep an identity/stale matrix_world after
58
+ their local transform is authored. Scatter has no native modifier
59
+ dependency to make Blender evaluate those hidden reference objects.
60
+ Snapshot their current poses while references and ancestors are enabled;
61
+ keep render visibility untouched and restore every viewport flag.
62
+ """
63
+ objects = list(dict.fromkeys(objects))
64
+ references = set(objects)
65
+ for obj in objects:
66
+ parent = obj.parent
67
+ while parent is not None and parent not in references:
68
+ references.add(parent)
69
+ parent = parent.parent
70
+ layer = bpy.context.view_layer
71
+ if any(obj.name not in layer.objects for obj in references):
72
+ _refuse('reference transform is outside the active view layer')
73
+ states = [(obj, obj.hide_viewport, obj.hide_get(view_layer=layer)) for obj in references]
74
+ try:
75
+ for obj, hidden, local_hidden in states:
76
+ if hidden:
77
+ obj.hide_viewport = False
78
+ if local_hidden:
79
+ obj.hide_set(False, view_layer=layer)
80
+ layer.update()
81
+ return {obj: obj.matrix_world.copy() for obj in objects}
82
+ finally:
83
+ for obj, hidden, local_hidden in states:
84
+ if obj.hide_get(view_layer=layer) != local_hidden:
85
+ obj.hide_set(local_hidden, view_layer=layer)
86
+ if obj.hide_viewport != hidden:
87
+ obj.hide_viewport = hidden
88
+ layer.update()
89
+
90
+
91
+ def _target(entity_id, obj, matrix):
55
92
  mesh = obj.data
56
93
  bm = bmesh.new()
57
94
  try:
@@ -60,7 +97,7 @@ def _target(entity_id, obj):
60
97
  _refuse('target "%s" needs a closed, non-degenerate solid mesh' % entity_id)
61
98
  finally:
62
99
  bm.free()
63
- points = [obj.matrix_world @ vertex.co for vertex in mesh.vertices]
100
+ points = [matrix @ vertex.co for vertex in mesh.vertices]
64
101
  if any(not all(math.isfinite(value) for value in point) for point in points):
65
102
  _refuse('target has non-finite world coordinates')
66
103
  mesh.calc_loop_triangles()
@@ -104,7 +141,8 @@ def apply_surface_scatter(obj, targets, entry):
104
141
  _refuse('targets exceed the 131072-polygon surface budget')
105
142
  if sum(max(0, len(polygon.vertices) - 2) for _, target in targets for polygon in target.data.polygons) > MAX_TARGET_TRIANGLES:
106
143
  _refuse('targets exceed the 262144-triangle surface budget before triangulation')
107
- target_records = [_target(key, target) for key, target in targets]
144
+ matrices = _world_matrices([obj] + [target for _, target in targets])
145
+ target_records = [_target(key, target, matrices[target]) for key, target in targets]
108
146
  if sum(len(target['triangles']) for target in target_records) > MAX_TARGET_TRIANGLES:
109
147
  _refuse('targets exceed the 262144-triangle surface budget')
110
148
  weighted, areas, total = [], [], 0.0
@@ -131,7 +169,7 @@ def apply_surface_scatter(obj, targets, entry):
131
169
  material_indices = [polygon.material_index for polygon in source.polygons]
132
170
  materials = list(source.materials)
133
171
  uv_maps = [(layer.name, [(item.uv.x, item.uv.y) for item in layer.data]) for layer in source.uv_layers]
134
- matrix = obj.matrix_world.copy()
172
+ matrix = matrices[obj]
135
173
  try:
136
174
  inverse = matrix.inverted()
137
175
  except ValueError:
@@ -39,6 +39,7 @@ from mathutils import Vector
39
39
  from deepblend_util import ActionError, Guard, as_text, error_text, report_progress, warning
40
40
  from deepblend_asset_bundle import verify_asset_bundle, verify_unbundled_gltf_asset, verify_unbundled_obj_asset
41
41
  from deepblend_geometry import create_lathe, create_curve, apply_model_modifiers
42
+ from deepblend_image_headers import MAX_IMAGE_DECODE_BYTES
42
43
  from deepblend_images import build_image_maps, build_environment, pack_imported_material_images, check_scene_image_budget
43
44
  from deepblend_parts import isolated_import_names, stamp_imported_parts, apply_material_bindings
44
45
  from deepblend_anisotropy import build_anisotropy, validate_anisotropy_material, validate_anisotropy_usage, validate_native_material_usage
@@ -2000,7 +2001,11 @@ def build_scene(spec, options, guard):
2000
2001
  verify_unbundled_gltf_asset(options.get("project_root") or "", asset)
2001
2002
  verify_unbundled_obj_asset(options.get("project_root") or "", asset)
2002
2003
 
2003
- check_scene_image_budget(spec, options.get('project_root'))
2004
+ image_budget = check_scene_image_budget(spec, options.get('project_root'))
2005
+ if image_budget['limitBytes'] != MAX_IMAGE_DECODE_BYTES:
2006
+ guard.note('SCENE_COMPILER_DECISION', 'using an explicit operator scene image budget',
2007
+ {'kind': 'scene-image-budget', **image_budget,
2008
+ 'allocationPolicy': 'conservative-per-imported-instance'})
2004
2009
  report_progress("reset_scene", 5)
2005
2010
  scene = reset_scene()
2006
2011