@deepblend/dsh-blender-provider-local 0.2.19 → 0.3.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.
Files changed (43) hide show
  1. package/lib/index.js +50 -35
  2. package/package.json +2 -2
  3. package/python/bootstrap.py +4 -2
  4. package/python/deepblend_anisotropy.py +110 -0
  5. package/python/deepblend_asset_bundle.py +193 -0
  6. package/python/deepblend_avif_headers.py +222 -0
  7. package/python/deepblend_bridge.py +46 -4
  8. package/python/deepblend_capabilities.py +7 -0
  9. package/python/deepblend_frames.py +11 -1
  10. package/python/deepblend_geometry.py +210 -0
  11. package/python/deepblend_gltf_images.py +183 -0
  12. package/python/deepblend_gltf_import.py +95 -0
  13. package/python/deepblend_gltf_roles.py +56 -0
  14. package/python/deepblend_image_headers.py +294 -0
  15. package/python/deepblend_image_roles.py +2 -0
  16. package/python/deepblend_images.py +203 -0
  17. package/python/deepblend_imported_image_headers.py +364 -0
  18. package/python/deepblend_mesh_checks.py +145 -0
  19. package/python/deepblend_obj_images.py +54 -0
  20. package/python/deepblend_obj_import.py +58 -0
  21. package/python/deepblend_obj_resources.py +79 -0
  22. package/python/deepblend_parts.py +106 -0
  23. package/python/deepblend_render.py +53 -0
  24. package/python/deepblend_scene.py +447 -51
  25. package/python/deepblend_vessel.py +192 -0
  26. package/python/deepblend_vessel_math.py +116 -0
  27. package/python/deepblend_vessel_parameters.py +65 -0
  28. package/python/deepblend_views.py +18 -19
  29. package/python/__pycache__/bootstrap.cpython-313.pyc +0 -0
  30. package/python/__pycache__/bootstrap.cpython-314.pyc +0 -0
  31. package/python/__pycache__/deepblend_bridge.cpython-313.pyc +0 -0
  32. package/python/__pycache__/deepblend_bridge.cpython-314.pyc +0 -0
  33. package/python/__pycache__/deepblend_capabilities.cpython-313.pyc +0 -0
  34. package/python/__pycache__/deepblend_frames.cpython-313.pyc +0 -0
  35. package/python/__pycache__/deepblend_frames.cpython-314.pyc +0 -0
  36. package/python/__pycache__/deepblend_render.cpython-313.pyc +0 -0
  37. package/python/__pycache__/deepblend_render.cpython-314.pyc +0 -0
  38. package/python/__pycache__/deepblend_scene.cpython-313.pyc +0 -0
  39. package/python/__pycache__/deepblend_scene.cpython-314.pyc +0 -0
  40. package/python/__pycache__/deepblend_util.cpython-313.pyc +0 -0
  41. package/python/__pycache__/deepblend_util.cpython-314.pyc +0 -0
  42. package/python/__pycache__/deepblend_validate.cpython-313.pyc +0 -0
  43. package/python/__pycache__/deepblend_views.cpython-313.pyc +0 -0
package/lib/index.js CHANGED
@@ -706,9 +706,17 @@ export default class LocalBlenderRuntime extends Service {
706
706
  // bare names against the provider's scrubbed PATH. Relative paths with
707
707
  // separators are rejected by the service itself.
708
708
  const resolved = await this.ctx.subprocess.resolveExecutable(requested, undefined, options.signal)
709
+ if (options.signal?.aborted) {
710
+ throw new BlenderError(BlenderErrorCode.ABORTED, 'Blender executable resolution was cancelled.')
711
+ }
709
712
  const canonical = this._assertAllowed(resolved, requested)
710
713
  return { resolved: canonical, requested, error: null }
711
714
  } catch (cause) {
715
+ if (options.signal?.aborted || cause?.name === 'AbortError') {
716
+ return { resolved: null, requested, error: new BlenderError(
717
+ BlenderErrorCode.ABORTED, 'Blender executable resolution was cancelled.', { cause },
718
+ ) }
719
+ }
712
720
  // The ADVICE travels with the failure, because two readers need the same sentence: the settings
713
721
  // card a human opens and the capability text a model reads. Composing it in either of them would
714
722
  // be a second copy of "what to do when there is no Blender", and this repository has paid for
@@ -1342,6 +1350,9 @@ export default class LocalBlenderRuntime extends Service {
1342
1350
  * @returns {Promise<import('@deepblend/dsh-blender-contracts').BlenderCapabilities>}
1343
1351
  */
1344
1352
  async getCapabilities(options = {}) {
1353
+ if (options.signal?.aborted) {
1354
+ throw new BlenderError(BlenderErrorCode.ABORTED, 'Blender capability probe was cancelled.')
1355
+ }
1345
1356
  const cacheKey = this._requestedBlenderPath()
1346
1357
  const cached = this._capabilitiesCache.get(cacheKey)
1347
1358
  const now = Date.now()
@@ -1350,6 +1361,7 @@ export default class LocalBlenderRuntime extends Service {
1350
1361
  }
1351
1362
 
1352
1363
  const resolved = await this.resolveBlenderExecutable({ signal: options.signal })
1364
+ if (resolved.error?.code === BlenderErrorCode.ABORTED) throw resolved.error
1353
1365
  if (resolved.resolved === null || resolved.error !== null) {
1354
1366
  const absent = this._absentCapabilities(resolved, resolved.error)
1355
1367
  this._capabilitiesCache.set(cacheKey, absent)
@@ -1474,6 +1486,7 @@ export default class LocalBlenderRuntime extends Service {
1474
1486
  * @param {string} [request.projectRoot]
1475
1487
  * @param {string} [request.jobId]
1476
1488
  * @param {AbortSignal} [request.signal]
1489
+ * @param {boolean} [request.session] - false forces an isolated batch process, even when compile_scene is configured for a kept/live session.
1477
1490
  * @returns {Promise<{report: object, envelope: object, durationMs: number, stdout: string, stderr: string}>}
1478
1491
  */
1479
1492
  async compileScene(request) {
@@ -1484,43 +1497,42 @@ export default class LocalBlenderRuntime extends Service {
1484
1497
  // checkpoint that belonged to a different one. Passing the path explicitly removes the dependency
1485
1498
  // on a working directory that a kept process does not have per request.
1486
1499
  const { directory: requestDirectory } = this._createWorkingDirectory()
1487
- const outputBlend = join(requestDirectory, 'result.blend')
1488
- if (typeof request?.sceneSpecPath !== 'string' || request.sceneSpecPath.length === 0) {
1489
- throw new BlenderError(
1490
- BlenderErrorCode.SCENE_SPEC_INVALID,
1491
- 'compileScene needs an absolute path to a SceneSpec document.',
1492
- )
1493
- }
1494
-
1495
- const args = ['--scene-spec', request.sceneSpecPath, '--output-blend', outputBlend]
1496
- if (request.profile !== undefined) args.push('--profile', String(request.profile))
1497
- if (request.projectRoot !== undefined) args.push('--project-root', request.projectRoot)
1500
+ try {
1501
+ const outputBlend = join(requestDirectory, 'result.blend')
1502
+ if (typeof request?.sceneSpecPath !== 'string' || request.sceneSpecPath.length === 0) {
1503
+ throw new BlenderError(
1504
+ BlenderErrorCode.SCENE_SPEC_INVALID,
1505
+ 'compileScene needs an absolute path to a SceneSpec document.',
1506
+ )
1507
+ }
1498
1508
 
1499
- const run = await this.runBootstrap(
1500
- { action: 'compile_scene', jobId: request.jobId },
1501
- {
1502
- signal: request.signal,
1503
- args,
1504
- projectRoot: request.projectRoot,
1505
- // NOT `onWorkingDirectory` here: the callback has to name the directory that HOLDS the
1506
- // checkpoint, and that is this method's own (`runBootstrap` would name its invocation
1507
- // directory, which in a session is a different one). One owner for one path.
1508
- },
1509
- )
1509
+ const args = ['--scene-spec', request.sceneSpecPath, '--output-blend', outputBlend]
1510
+ if (request.profile !== undefined) args.push('--profile', String(request.profile))
1511
+ if (request.projectRoot !== undefined) args.push('--project-root', request.projectRoot)
1512
+
1513
+ const run = await this.runBootstrap(
1514
+ { action: 'compile_scene', jobId: request.jobId },
1515
+ {
1516
+ signal: request.signal,
1517
+ session: request.session,
1518
+ args,
1519
+ projectRoot: request.projectRoot,
1520
+ // This method owns the directory containing the checkpoint; the
1521
+ // bootstrap invocation has a separate directory in batch mode.
1522
+ },
1523
+ )
1510
1524
 
1511
- // The caller moves the checkpoint somewhere durable before this directory goes away, which is what
1512
- // lets a compile failure leave no trace in the project.
1513
- if (typeof request.onWorkingDirectory === 'function') {
1514
- await request.onWorkingDirectory({ directory: requestDirectory, envelope: run.envelope, jobId: request.jobId })
1515
- }
1525
+ // Await consumption before cleanup, including asynchronous moves/copies.
1526
+ if (typeof request.onWorkingDirectory === 'function') {
1527
+ await request.onWorkingDirectory({ directory: requestDirectory, envelope: run.envelope, jobId: request.jobId })
1528
+ }
1516
1529
 
1517
- const report = run.envelope.result ?? {}
1518
- return {
1519
- report,
1520
- envelope: run.envelope,
1521
- durationMs: run.durationMs,
1522
- stdout: run.stdout,
1523
- stderr: run.stderr,
1530
+ const report = run.envelope.result ?? {}
1531
+ return { report, envelope: run.envelope, durationMs: run.durationMs, stdout: run.stdout, stderr: run.stderr }
1532
+ } finally {
1533
+ // Includes compile failure, cancellation and consumer callback failure.
1534
+ // Hosts never have to guess provider-owned temporary paths to remove them.
1535
+ this._cleanup(requestDirectory)
1524
1536
  }
1525
1537
  }
1526
1538
 
@@ -1538,6 +1550,7 @@ export default class LocalBlenderRuntime extends Service {
1538
1550
  * @param {number} [request.frame]
1539
1551
  * @param {string} [request.jobId]
1540
1552
  * @param {AbortSignal} [request.signal]
1553
+ * @param {boolean} [request.session] - false forces an isolated batch process; omission preserves configured session routing.
1541
1554
  * @returns {Promise<{report: object, envelope: object, durationMs: number, stdout: string, stderr: string}>}
1542
1555
  */
1543
1556
  async renderPreview(request) {
@@ -1561,7 +1574,7 @@ export default class LocalBlenderRuntime extends Service {
1561
1574
 
1562
1575
  const run = await this.runBootstrap(
1563
1576
  { action: 'render_preview', jobId: request.jobId },
1564
- { signal: request.signal, args, onWorkingDirectory: request.onWorkingDirectory },
1577
+ { signal: request.signal, session: request.session, args, onWorkingDirectory: request.onWorkingDirectory },
1565
1578
  )
1566
1579
 
1567
1580
  const report = run.envelope.result ?? {}
@@ -1604,6 +1617,7 @@ export default class LocalBlenderRuntime extends Service {
1604
1617
  * @param {number} [request.samples]
1605
1618
  * @param {string} [request.jobId]
1606
1619
  * @param {AbortSignal} [request.signal]
1620
+ * @param {boolean} [request.session] - false forces batch isolation; omission preserves configured session routing.
1607
1621
  * @returns {Promise<{report: object, pngs: Record<string, Buffer>, envelope: object, durationMs: number}>}
1608
1622
  */
1609
1623
  /**
@@ -1894,6 +1908,7 @@ export default class LocalBlenderRuntime extends Service {
1894
1908
  { action: 'render_views', jobId: request.jobId },
1895
1909
  {
1896
1910
  signal: request.signal,
1911
+ session: request.session,
1897
1912
  args: ['--views', 'views.json'],
1898
1913
  // The plan names outputs by BARE file name: Blender must write somewhere
1899
1914
  // that dies with the invocation, or a failed render would leave images
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-provider-local",
3
- "version": "0.2.19",
3
+ "version": "0.3.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.2.19"
14
+ "@deepblend/dsh-blender-contracts": "0.3.0"
15
15
  },
16
16
  "license": "MIT",
17
17
  "repository": {
@@ -218,10 +218,12 @@ def action_compile_scene(request, options):
218
218
 
219
219
  report_progress("validate", 92)
220
220
  validation = validate_scene(bpy.context.scene, spec, guard)
221
+ objects = describe_objects()
221
222
 
222
223
  payload = {
223
224
  "sceneSpec": spec_path,
224
- "objects": describe_objects(),
225
+ "objects": objects,
226
+ "world": report["world"],
225
227
  "actions": report["actions"],
226
228
  "renderConfig": report["renderConfig"],
227
229
  "engine": report["engine"],
@@ -262,7 +264,7 @@ def action_compile_scene(request, options):
262
264
  )
263
265
  ],
264
266
  "validation": validation,
265
- "sceneFingerprint": scene_fingerprint(spec, describe_objects()),
267
+ "sceneFingerprint": scene_fingerprint(spec, objects),
266
268
  }
267
269
 
268
270
  # The checkpoint is written even when validation reported errors: the caller
@@ -0,0 +1,110 @@
1
+ """Explicit Principled anisotropy with Blender UV or radial tangent directions.
2
+
3
+ Blender 5.2's Principled Anisotropic/Anisotropic Rotation are Cycles-only.
4
+ Rotation is measured in turns, not radians. RADIAL uses Blender's cylindrical
5
+ projection about the object's local X/Y/Z axis; UV_MAP uses the named UV map.
6
+ https://docs.blender.org/manual/en/5.2/render/shader_nodes/shader/principled.html
7
+ https://docs.blender.org/manual/en/4.3/render/shader_nodes/input/tangent.html
8
+ """
9
+ from deepblend_util import ActionError
10
+ from deepblend_images import validate_image_uv_usage, validate_procedural_uv_usage
11
+
12
+ KEYS = ('anisotropic', 'anisotropicRotation')
13
+
14
+
15
+ def anisotropy_state(spec, tracks=()):
16
+ parameters = spec.get('parameters') or {}
17
+ animations = [track for track in tracks if track.get('targetKind') == 'material'
18
+ and track.get('targetEntityId') == spec['id'] and track.get('property') in KEYS]
19
+ requested = spec.get('tangent') is not None or animations or any(key in parameters for key in KEYS)
20
+ direction_required = any(parameters.get(key, 0) != 0 for key in KEYS) or any(
21
+ key.get('value', 0) != 0 for track in animations for key in track.get('keyframes', []))
22
+ active = parameters.get('anisotropic', 0) > 0 or any(
23
+ key.get('value', 0) > 0 for track in animations if track['property'] == 'anisotropic'
24
+ for key in track.get('keyframes', []))
25
+ return requested, direction_required, active
26
+
27
+
28
+ def validate_anisotropy_material(spec, tracks=()):
29
+ requested, direction_required, active = anisotropy_state(spec, tracks)
30
+ if requested and spec.get('shader') == 'emission':
31
+ raise ActionError('SCENE_VALIDATION_FAILED', 'anisotropy requires a principled or glass material', {'materialId': spec['id']})
32
+ if direction_required and spec.get('tangent') is None:
33
+ raise ActionError('SCENE_VALIDATION_FAILED', 'nonzero anisotropy or rotation requires an explicit tangent', {'materialId': spec['id']})
34
+ return active
35
+
36
+
37
+ def build_anisotropy(material, principled, spec):
38
+ """Connect a real tangent node; do not depend on Blender's implicit UV fallback."""
39
+ requested, _, _ = anisotropy_state(spec)
40
+ if not requested:
41
+ return
42
+ validate_anisotropy_material(spec)
43
+ for socket in ('Anisotropic', 'Anisotropic Rotation', 'Tangent'):
44
+ if principled.inputs.get(socket) is None:
45
+ raise ActionError('SCENE_VALIDATION_FAILED', 'this Blender build lacks Principled ' + socket, {'materialId': spec['id']})
46
+ binding = spec.get('tangent')
47
+ if binding is None:
48
+ return
49
+ node = material.node_tree.nodes.new('ShaderNodeTangent')
50
+ node.name = 'db_anisotropic_tangent'
51
+ node.location = (-270, -740)
52
+ if binding['mode'] == 'uv':
53
+ node.direction_type = 'UV_MAP'
54
+ node.uv_map = binding['uvMap']
55
+ elif binding['mode'] == 'radial':
56
+ node.direction_type = 'RADIAL'
57
+ node.axis = binding['axis'].upper()
58
+ else:
59
+ raise ActionError('SCENE_VALIDATION_FAILED', 'unknown anisotropic tangent mode', {'materialId': spec['id']})
60
+ material.node_tree.links.new(node.outputs['Tangent'], principled.inputs['Tangent'])
61
+
62
+
63
+ def validate_anisotropy_usage(spec, mesh, engine, tracks=(), uv_layers=None):
64
+ """Called only for materials actually used by a mesh face, after slot overrides."""
65
+ active = validate_anisotropy_material(spec, tracks)
66
+ if active and engine != 'CYCLES':
67
+ raise ActionError('SCENE_VALIDATION_FAILED', 'Principled anisotropy requires Cycles for this used material',
68
+ {'materialId': spec['id'], 'entityId': mesh.get('deepblend_id'), 'engine': engine})
69
+ _, direction_required, _ = anisotropy_state(spec, tracks)
70
+ binding = spec.get('tangent')
71
+ layers = uv_layers if uv_layers is not None else getattr(mesh.data, 'uv_layers', None)
72
+ if direction_required and binding and binding['mode'] == 'uv' and (layers is None or layers.get(binding['uvMap']) is None):
73
+ raise ActionError('SCENE_VALIDATION_FAILED', 'anisotropic material requires its named UV map on the used mesh',
74
+ {'materialId': spec['id'], 'entityId': mesh.get('deepblend_id'),
75
+ 'partId': mesh.get('deepblend_part_id'), 'uvMap': binding['uvMap']})
76
+ return active
77
+
78
+
79
+ def validate_native_material_usage(spec, material, obj, engine, tracks, depsgraph):
80
+ """Inspect evaluated faces without converting or changing the imported object.
81
+
82
+ Curves, surfaces and text can render a material while remaining native data.
83
+ A named UV map must exist on the evaluated surface; implicit fallback is not
84
+ proof. Unused slots and unrelated materials never trigger this inspection.
85
+ """
86
+ _, direction_required, active = anisotropy_state(spec, tracks)
87
+ assigned = {index for index, slot in enumerate(obj.material_slots) if slot.material == material}
88
+ if not assigned or not (direction_required or active or spec.get('images') or
89
+ (spec.get('texture') or {}).get('coordinates') == 'uv'):
90
+ return False
91
+ evaluated = obj.evaluated_get(depsgraph)
92
+ try:
93
+ surface = evaluated.to_mesh(preserve_all_data_layers=True, depsgraph=depsgraph)
94
+ if surface is None:
95
+ raise ActionError('SCENE_VALIDATION_FAILED', 'cannot verify anisotropic direction on this native surface',
96
+ {'materialId': spec['id'], 'entityId': obj.get('deepblend_id'), 'objectType': obj.type})
97
+ if any(polygon.material_index in assigned for polygon in surface.polygons):
98
+ validate_image_uv_usage(spec, obj, surface.uv_layers)
99
+ validate_procedural_uv_usage(spec, obj, surface.uv_layers)
100
+ return validate_anisotropy_usage(spec, obj, engine, tracks, uv_layers=surface.uv_layers)
101
+ return False
102
+ finally:
103
+ evaluated.to_mesh_clear()
104
+
105
+
106
+ def validate_anisotropy_render_engine(scene, engine):
107
+ """Keep the saved scene's used-material requirement through render overrides."""
108
+ if scene.get('deepblend_requires_cycles', False) and engine != 'CYCLES':
109
+ raise ActionError('SCENE_VALIDATION_FAILED', 'this checkpoint uses animated or static anisotropy and requires Cycles',
110
+ {'engine': engine, 'requiredEngine': 'CYCLES'})
@@ -0,0 +1,193 @@
1
+ """Verify managed glTF bundles before any scene mutation; no bpy dependency."""
2
+ import hashlib,json,os,posixpath,re,struct
3
+ from urllib.parse import unquote
4
+ from deepblend_util import ActionError
5
+ from deepblend_obj_resources import obj_libraries,mtl_images
6
+ LOCK='.deepblend-lock.json'
7
+ MAX_FILES=256
8
+ MAX_LOCK=1024*1024
9
+ MAX_JSON=16*1024*1024
10
+ MAX_CHUNKS=1024
11
+
12
+ def fail(message,code='ASSET_REQUEST_INVALID'):
13
+ raise ActionError(code,message)
14
+
15
+ def member_path(path):
16
+ if not isinstance(path,str) or not path or len(path)>1024 or '\\' in path or path.startswith('/') or re.search(r'[\x00-\x1f]',path) or any(not p or p in ('.','..') or re.search(r'[:<>"|?*]',p) for p in path.split('/')):
17
+ fail('Asset bundle members need contained, portable relative paths.')
18
+ return path
19
+
20
+ def inside(root,path):
21
+ base=os.path.realpath(root);target=os.path.realpath(os.path.join(base,path))
22
+ if target!=base and not target.startswith(base+os.sep):
23
+ fail('An asset bundle path escapes its directory.','PATH_OUTSIDE_WORKSPACE')
24
+ return target
25
+
26
+ def read_bytes(path,limit):
27
+ try:
28
+ if os.path.getsize(path)>limit:
29
+ fail('The asset bundle metadata exceeds its byte limit.','ASSET_TOO_LARGE')
30
+ with open(path,'rb') as source:
31
+ data=source.read(limit+1)
32
+ if len(data)>limit:
33
+ fail('The asset bundle metadata exceeds its byte limit.','ASSET_TOO_LARGE')
34
+ return data
35
+ except OSError:
36
+ fail('An asset bundle file is missing.','ASSET_MISSING')
37
+
38
+ def read_json(path,limit):
39
+ data=read_bytes(path,limit)
40
+ try:return data,json.loads(data)
41
+ except (ValueError,UnicodeError):fail('The asset bundle metadata cannot be parsed.','ASSET_CONTENT_MISMATCH')
42
+
43
+ def parse_document(data):
44
+ try:return json.loads(data.decode('utf-8-sig'),parse_constant=lambda value:fail('Non-finite glTF JSON value.','ASSET_CONTENT_MISMATCH'))
45
+ except (ValueError,UnicodeError):fail('The glTF metadata cannot be parsed.','ASSET_CONTENT_MISMATCH')
46
+
47
+ def read_document(path,format='gltf',include_bin_location=False):
48
+ if format=='gltf':return parse_document(read_bytes(path,MAX_JSON)),None
49
+ if format!='glb':fail('Unsupported asset bundle format.')
50
+ try:
51
+ with open(path,'rb') as source:
52
+ size=os.fstat(source.fileno()).st_size
53
+ def read(offset,length):
54
+ source.seek(offset);data=source.read(length)
55
+ if len(data)!=length:fail('The GLB is truncated.','ASSET_CONTENT_MISMATCH')
56
+ return data
57
+ if size<20:fail('The GLB header is truncated.','ASSET_CONTENT_MISMATCH')
58
+ magic,version,declared=struct.unpack('<4sII',read(0,12))
59
+ if magic!=b'glTF' or version!=2 or declared!=size:fail('The GLB must be a complete version 2 container.','ASSET_CONTENT_MISMATCH')
60
+ offset=12;document=None;bin_bytes=None;bin_offset=None;chunks=0;json_seen=False
61
+ while offset<size:
62
+ chunks+=1
63
+ if chunks>MAX_CHUNKS:fail('The GLB exceeds the chunk inspection limit.','ASSET_TOO_LARGE')
64
+ if offset+8>size:fail('The GLB chunk header is truncated.','ASSET_CONTENT_MISMATCH')
65
+ length,kind=struct.unpack('<II',read(offset,8))
66
+ if length%4 or offset+8+length>size:fail('The GLB chunk length is invalid.','ASSET_CONTENT_MISMATCH')
67
+ if chunks==1 and kind!=0x4e4f534a:fail('The first GLB chunk must contain JSON.','ASSET_CONTENT_MISMATCH')
68
+ if kind==0x4e4f534a:
69
+ if json_seen:fail('The GLB contains duplicate JSON chunks.','ASSET_CONTENT_MISMATCH')
70
+ if length>MAX_JSON:fail('The GLB JSON exceeds 16 MiB.','ASSET_TOO_LARGE')
71
+ json_seen=True;document=parse_document(read(offset+8,length))
72
+ elif kind==0x004e4942:
73
+ if chunks!=2 or bin_bytes is not None:fail('The GLB BIN must be its second chunk.','ASSET_CONTENT_MISMATCH')
74
+ bin_bytes=length;bin_offset=offset+8
75
+ offset+=8+length
76
+ if not isinstance(document,dict) or not isinstance(document.get('asset'),dict) or document['asset'].get('version')!='2.0':fail('The GLB JSON must declare glTF 2.0.','ASSET_CONTENT_MISMATCH')
77
+ return document,({'offset':bin_offset,'bytes':bin_bytes} if include_bin_location and bin_bytes is not None else bin_bytes)
78
+ except OSError:fail('A glTF asset file is missing.','ASSET_MISSING')
79
+
80
+ def resources(document,entrypoint,bin_bytes=None):
81
+ if not isinstance(document,dict) or not isinstance(document.get('asset'),dict) or document['asset'].get('version')!='2.0':
82
+ fail('The glTF JSON must declare version 2.0.','ASSET_CONTENT_MISMATCH')
83
+ paths=set()
84
+ for key in ('buffers','images'):
85
+ entries=document.get(key,[])
86
+ if not isinstance(entries,list):fail('glTF resources must be arrays.')
87
+ for index,item in enumerate(entries):
88
+ if not isinstance(item,dict):fail('Invalid glTF resource entry.')
89
+ uri=item.get('uri')
90
+ if uri is None and 'uri' not in item:
91
+ if key=='buffers' and (index!=0 or bin_bytes is None or type(item.get('byteLength')) is not int or not 0<=item['byteLength']<=bin_bytes or bin_bytes-item['byteLength']>3):fail('An embedded buffer requires the first GLB buffer and a matching BIN chunk.')
92
+ continue
93
+ if not isinstance(uri,str) or not uri:fail('glTF resource URIs must be nonempty strings.')
94
+ if uri.startswith('data:'):
95
+ if not re.fullmatch(r'data:[^,]*;base64,[A-Za-z0-9+/]*={0,2}',uri) or len(uri.split(',')[1])%4:fail('glTF embedded resources need valid base64 data URIs.')
96
+ continue
97
+ if uri.startswith('/') or '\\' in uri or '?' in uri or '#' in uri or re.match(r'^[a-z][a-z0-9+.-]*:',uri,re.I):fail('glTF resources must use relative file URIs or embedded data.')
98
+ if re.search(r'%(?![a-f0-9]{2})',uri,re.I):fail('A glTF resource URI has invalid percent encoding.')
99
+ try:decoded=unquote(uri,errors='strict')
100
+ except UnicodeError:fail('A glTF resource URI has invalid percent encoding.')
101
+ if decoded.startswith('/') or '\\' in decoded or re.search(r'[\x00-\x1f]',decoded):fail('Invalid glTF resource path.')
102
+ path=member_path(posixpath.normpath(posixpath.join(posixpath.dirname(entrypoint),decoded)))
103
+ if path==LOCK:fail('A glTF resource collides with the reserved bundle lock.')
104
+ paths.add(path)
105
+ queue=[(document,False)];index=0
106
+ while index<len(queue):
107
+ if index>500000:fail('The glTF resource graph exceeds its inspection limit.','ASSET_TOO_LARGE')
108
+ value,extension=queue[index];index+=1
109
+ if isinstance(value,dict):
110
+ if extension and isinstance(value.get('uri'),str) and not value['uri'].lower().startswith('data:'):fail('External URI resources inside glTF extensions need a supported dependency profile.')
111
+ queue.extend((child,extension or key=='extensions') for key,child in value.items() if isinstance(child,(dict,list)))
112
+ elif isinstance(value,list):queue.extend((child,extension) for child in value if isinstance(child,(dict,list)))
113
+ if len(paths)+1>MAX_FILES:fail('The glTF bundle exceeds its file limit.','ASSET_TOO_LARGE')
114
+ folded=set()
115
+ for path in [entrypoint]+list(paths):
116
+ key=path.lower()
117
+ if key in folded and path!=entrypoint:fail('glTF resource paths collide on case-insensitive filesystems.')
118
+ folded.add(key)
119
+ return paths
120
+
121
+ def verify_asset_bundle(project_root,asset):
122
+ match=re.fullmatch(r'assets/bundles/([a-f0-9]{64})/(.+)',asset.get('path',''))
123
+ if not match:return None
124
+ root=inside(project_root,'assets/bundles/'+match.group(1));data=read_bytes(inside(root,LOCK),MAX_LOCK)
125
+ if hashlib.sha256(data).hexdigest()!=match.group(1):fail('The asset bundle lock changed.','ASSET_HASH_MISMATCH')
126
+ try:manifest=json.loads(data)
127
+ except (ValueError,UnicodeError):fail('The asset bundle lock cannot be parsed.','ASSET_CONTENT_MISMATCH')
128
+ if not isinstance(manifest,dict) or manifest.get('schemaVersion')!='deepblend.asset-bundle/v1' or manifest.get('entrypoint')!=match.group(2) or not isinstance(manifest.get('files'),list) or not 1<=len(manifest['files'])<=MAX_FILES:
129
+ fail('The asset bundle lock has an invalid shape or entrypoint.')
130
+ member_path(manifest['entrypoint']);format=manifest.get('format','gltf')
131
+ if format not in ('gltf','glb','obj') or (asset.get('type') and asset['type']!=format):fail('The asset bundle format does not match its declaration.')
132
+ members={};folded=set();total=0
133
+ for member in manifest['files']:
134
+ if not isinstance(member,dict):fail('Invalid asset bundle member.')
135
+ path=member_path(member.get('path'));size=member.get('bytes');sha=member.get('sha256')
136
+ if path==LOCK or path in members or path.lower() in folded or not isinstance(sha,str) or not re.fullmatch(r'[a-f0-9]{64}',sha) or type(size) is not int or not 0<=size<=9007199254740991:
137
+ fail('The asset bundle lock contains invalid or duplicate members.')
138
+ file=inside(root,path);digest=hashlib.sha256();actual_size=0
139
+ try:
140
+ with open(file,'rb') as source:
141
+ for chunk in iter(lambda:source.read(64*1024),b''):
142
+ actual_size+=len(chunk)
143
+ if actual_size>size:fail('An asset bundle member changed size.','ASSET_HASH_MISMATCH')
144
+ digest.update(chunk)
145
+ except OSError:fail('An asset bundle member is missing.','ASSET_MISSING')
146
+ if actual_size!=size or digest.hexdigest()!=sha:fail('An asset bundle member changed.','ASSET_HASH_MISMATCH')
147
+ members[path]=member;folded.add(path.lower());total+=size
148
+ main=members.get(manifest['entrypoint'])
149
+ if total>9007199254740991 or type(manifest.get('totalBytes')) is not int or manifest['totalBytes']!=total or main is None or (asset.get('sha256') and asset['sha256']!=main['sha256']):
150
+ fail('The asset bundle identity does not agree with its declaration.','ASSET_HASH_MISMATCH')
151
+ if format=='obj':paths=obj_resources(root,manifest['entrypoint'])
152
+ else:
153
+ document,bin_bytes=read_document(inside(root,manifest['entrypoint']),format)
154
+ paths=resources(document,manifest['entrypoint'],bin_bytes)
155
+ for path in paths:
156
+ if path not in members:fail('A glTF resource is not locked.')
157
+ return manifest
158
+
159
+ def verify_unbundled_gltf_asset(project_root,asset):
160
+ if asset.get('type') not in ('gltf','glb') or re.match(r'assets/bundles/[a-f0-9]{64}/',asset.get('path','')):return
161
+ document,bin_bytes=read_document(inside(project_root,asset.get('path','')),asset['type'])
162
+ if resources(document,posixpath.basename(asset.get('path','')),bin_bytes):fail('This glTF/GLB has unlocked external resources. Reimport the local source with a containing sourceRoot.')
163
+
164
+
165
+ def obj_reference(entrypoint,name):
166
+ if not name or name.startswith('/') or '\\' in name or re.match(r'^[a-z][a-z0-9+.-]*:',name,re.I):
167
+ fail('OBJ resources require portable relative file paths. No dependency URLs are fetched.')
168
+ path=member_path(posixpath.normpath(posixpath.join(posixpath.dirname(entrypoint),name)))
169
+ if path==LOCK:fail('An OBJ resource collides with the reserved bundle lock.')
170
+ return path
171
+
172
+ def obj_resources(root,entrypoint):
173
+ libraries={obj_reference(entrypoint,name) for name in obj_libraries(inside(root,entrypoint))}
174
+ fallback=posixpath.splitext(entrypoint)[0]+'.mtl'
175
+ if os.path.exists(inside(root,fallback)):libraries.add(fallback)
176
+ paths=set(libraries);folded={entrypoint.lower():entrypoint}
177
+ def check(path):
178
+ previous=folded.get(path.lower())
179
+ if previous is not None and previous!=path:fail('OBJ resource paths collide on case-insensitive filesystems.')
180
+ folded[path.lower()]=path
181
+ if len(paths|{entrypoint})>MAX_FILES:fail('The OBJ bundle exceeds its resource file limit.','ASSET_TOO_LARGE')
182
+ for library in libraries:
183
+ check(library)
184
+ for name in mtl_images(inside(root,library)):
185
+ path=obj_reference(library,name);paths.add(path);check(path)
186
+ for path in paths:check(path)
187
+ return paths
188
+
189
+ def verify_unbundled_obj_asset(project_root,asset):
190
+ if asset.get('type')!='obj' or re.match(r'assets/bundles/[a-f0-9]{64}/',asset.get('path','')):return
191
+ file=inside(project_root,asset.get('path',''));fallback=posixpath.splitext(asset.get('path',''))[0]+'.mtl'
192
+ if obj_libraries(file) or os.path.exists(inside(project_root,fallback)):
193
+ fail('This OBJ has unlocked material resources. Reimport the complete local source with a containing sourceRoot.')