@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.
- package/lib/index.js +50 -35
- package/package.json +2 -2
- package/python/bootstrap.py +4 -2
- package/python/deepblend_anisotropy.py +110 -0
- package/python/deepblend_asset_bundle.py +193 -0
- package/python/deepblend_avif_headers.py +222 -0
- package/python/deepblend_bridge.py +46 -4
- package/python/deepblend_capabilities.py +7 -0
- package/python/deepblend_frames.py +11 -1
- package/python/deepblend_geometry.py +210 -0
- package/python/deepblend_gltf_images.py +183 -0
- package/python/deepblend_gltf_import.py +95 -0
- package/python/deepblend_gltf_roles.py +56 -0
- package/python/deepblend_image_headers.py +294 -0
- package/python/deepblend_image_roles.py +2 -0
- package/python/deepblend_images.py +203 -0
- package/python/deepblend_imported_image_headers.py +364 -0
- package/python/deepblend_mesh_checks.py +145 -0
- package/python/deepblend_obj_images.py +54 -0
- package/python/deepblend_obj_import.py +58 -0
- package/python/deepblend_obj_resources.py +79 -0
- package/python/deepblend_parts.py +106 -0
- package/python/deepblend_render.py +53 -0
- package/python/deepblend_scene.py +447 -51
- package/python/deepblend_vessel.py +192 -0
- package/python/deepblend_vessel_math.py +116 -0
- package/python/deepblend_vessel_parameters.py +65 -0
- package/python/deepblend_views.py +18 -19
- package/python/__pycache__/bootstrap.cpython-313.pyc +0 -0
- package/python/__pycache__/bootstrap.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_bridge.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_bridge.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_capabilities.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_frames.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_render.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_render.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_scene.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_util.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_util.cpython-314.pyc +0 -0
- package/python/__pycache__/deepblend_validate.cpython-313.pyc +0 -0
- package/python/__pycache__/deepblend_views.cpython-313.pyc +0 -0
package/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
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
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
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
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
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
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
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
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.
|
|
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.
|
|
14
|
+
"@deepblend/dsh-blender-contracts": "0.3.0"
|
|
15
15
|
},
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|
package/python/bootstrap.py
CHANGED
|
@@ -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":
|
|
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,
|
|
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.')
|