@deepblend/dsh-blender-provider-local 0.2.20 → 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 +307 -49
- 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
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Inspect selected glTF textures without decoding pixels or whole BIN buffers."""
|
|
2
|
+
import base64
|
|
3
|
+
import binascii
|
|
4
|
+
import os
|
|
5
|
+
import posixpath
|
|
6
|
+
import re
|
|
7
|
+
from urllib.parse import unquote
|
|
8
|
+
|
|
9
|
+
from deepblend_asset_bundle import inside, member_path, read_document, resources
|
|
10
|
+
from deepblend_image_headers import inspect_image_header, MAX_IMAGE_DECODE_BYTES
|
|
11
|
+
from deepblend_util import ActionError
|
|
12
|
+
from deepblend_gltf_roles import texture_uses
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def invalid(message):
|
|
16
|
+
raise ValueError(message)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def integer(value, minimum=0):
|
|
20
|
+
if type(value) is not int or value < minimum:
|
|
21
|
+
invalid('image buffer references require nonnegative integer bounds')
|
|
22
|
+
return value
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def indexed(entries, index):
|
|
26
|
+
index = integer(index)
|
|
27
|
+
if not isinstance(entries, list) or index >= len(entries) or not isinstance(entries[index], dict):
|
|
28
|
+
invalid('image reference names a missing glTF resource')
|
|
29
|
+
return entries[index]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FileSlice:
|
|
33
|
+
def __init__(self, file, offset, length):
|
|
34
|
+
self.file, self.remaining = file, length
|
|
35
|
+
file.seek(offset)
|
|
36
|
+
|
|
37
|
+
def read(self, count):
|
|
38
|
+
if count < 0:
|
|
39
|
+
invalid('unbounded image buffer read')
|
|
40
|
+
data = self.file.read(min(count, self.remaining))
|
|
41
|
+
self.remaining -= len(data)
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class DataSlice:
|
|
46
|
+
def __init__(self, uri, offset=0, length=None):
|
|
47
|
+
if not isinstance(uri, str) or not re.fullmatch(r'data:[^,]*;base64,[A-Za-z0-9+/]*={0,2}', uri):
|
|
48
|
+
invalid('image buffers require valid base64 data URIs')
|
|
49
|
+
self.uri, self.start = uri, uri.index(',') + 1
|
|
50
|
+
encoded = len(uri) - self.start
|
|
51
|
+
if encoded % 4:
|
|
52
|
+
invalid('image buffers require complete base64 quartets')
|
|
53
|
+
self.size = encoded // 4 * 3 - (len(uri) - len(uri.rstrip('=')))
|
|
54
|
+
self.position, self.end = offset, self.size if length is None else offset + length
|
|
55
|
+
if not 0 <= self.position <= self.end <= self.size:
|
|
56
|
+
invalid('image view escapes its embedded buffer')
|
|
57
|
+
|
|
58
|
+
def read(self, count):
|
|
59
|
+
if count < 0:
|
|
60
|
+
invalid('unbounded embedded image read')
|
|
61
|
+
end = min(self.position + count, self.end)
|
|
62
|
+
first, last = self.position // 3 * 4, (end + 2) // 3 * 4
|
|
63
|
+
if end == self.position:
|
|
64
|
+
return b''
|
|
65
|
+
# Decode only the quartets needed by this header read, even when a
|
|
66
|
+
# small image is located near the end of a large embedded buffer.
|
|
67
|
+
data = base64.b64decode(self.uri[self.start + first:self.start + last], validate=True)
|
|
68
|
+
offset = self.position % 3
|
|
69
|
+
data = data[offset:offset + end - self.position]
|
|
70
|
+
self.position = end
|
|
71
|
+
return data
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def relative_resource(root, entrypoint, uri):
|
|
75
|
+
if not isinstance(uri, str) or not uri or uri.startswith('/') or '\\' in uri or '?' in uri or '#' in uri or re.match(r'^[a-z][a-z0-9+.-]*:', uri, re.I):
|
|
76
|
+
invalid('image resources require contained relative file URIs')
|
|
77
|
+
if re.search(r'%(?![a-f0-9]{2})', uri, re.I):
|
|
78
|
+
invalid('invalid image resource percent encoding')
|
|
79
|
+
decoded = unquote(uri, errors='strict')
|
|
80
|
+
path = member_path(posixpath.normpath(posixpath.join(posixpath.dirname(entrypoint), decoded)))
|
|
81
|
+
return inside(root, path)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def image_facts(root, entrypoint, path, document, binary, image):
|
|
85
|
+
uri = image.get('uri')
|
|
86
|
+
if uri is not None:
|
|
87
|
+
if 'bufferView' in image:
|
|
88
|
+
invalid('an image cannot declare both a URI and a buffer view')
|
|
89
|
+
if isinstance(uri, str) and uri.startswith('data:'):
|
|
90
|
+
return inspect_image_header(DataSlice(uri))
|
|
91
|
+
with open(relative_resource(root, entrypoint, uri), 'rb') as source:
|
|
92
|
+
return inspect_image_header(source)
|
|
93
|
+
view = indexed(document.get('bufferViews'), image.get('bufferView'))
|
|
94
|
+
if not isinstance(image.get('mimeType'), str):
|
|
95
|
+
invalid('buffer-view images require a media type')
|
|
96
|
+
if 'byteStride' in view or any(key in (view.get('extensions') or {}) for key in ('EXT_meshopt_compression', 'KHR_meshopt_compression')):
|
|
97
|
+
invalid('image views require contiguous encoded image bytes')
|
|
98
|
+
offset, length = integer(view.get('byteOffset', 0)), integer(view.get('byteLength'), 1)
|
|
99
|
+
buffer_index = integer(view.get('buffer'))
|
|
100
|
+
buffer = indexed(document.get('buffers'), buffer_index)
|
|
101
|
+
buffer_length = integer(buffer.get('byteLength'))
|
|
102
|
+
if offset + length > buffer_length:
|
|
103
|
+
invalid('image view escapes its declared buffer length')
|
|
104
|
+
buffer_uri = buffer.get('uri')
|
|
105
|
+
if isinstance(buffer_uri, str) and buffer_uri.startswith('data:'):
|
|
106
|
+
data = DataSlice(buffer_uri, offset, length)
|
|
107
|
+
if data.size < buffer_length:
|
|
108
|
+
invalid('embedded buffer is shorter than its declared length')
|
|
109
|
+
return inspect_image_header(data)
|
|
110
|
+
if buffer_uri is not None:
|
|
111
|
+
file = relative_resource(root, entrypoint, buffer_uri)
|
|
112
|
+
with open(file, 'rb') as source:
|
|
113
|
+
if os.fstat(source.fileno()).st_size < buffer_length:
|
|
114
|
+
invalid('image buffer file is shorter than its declared length')
|
|
115
|
+
return inspect_image_header(FileSlice(source, offset, length))
|
|
116
|
+
if buffer_index != 0 or binary is None or not 0 <= binary['bytes'] - buffer_length <= 3:
|
|
117
|
+
invalid('image buffer requires the first GLB buffer and a matching BIN chunk')
|
|
118
|
+
with open(path, 'rb') as source:
|
|
119
|
+
return inspect_image_header(FileSlice(source, binary['offset'] + offset, length))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def inspect_gltf_images(project_root, asset):
|
|
123
|
+
"""Match the pinned native importer's default core/WebP source choice.
|
|
124
|
+
|
|
125
|
+
Count each selected image once per import, including texture declarations
|
|
126
|
+
which may be unused in the default scene. Repeated import costs are added
|
|
127
|
+
by the scene budget; external native image reuse can make that estimate
|
|
128
|
+
conservative. Unselected fallback formats stay available to other readers.
|
|
129
|
+
"""
|
|
130
|
+
try:
|
|
131
|
+
bundle = re.fullmatch(r'assets/bundles/([a-f0-9]{64})/(.+)', asset['path'])
|
|
132
|
+
root = inside(project_root or '.', 'assets/bundles/' + bundle[1]) if bundle else os.path.realpath(project_root or '.')
|
|
133
|
+
entrypoint = bundle[2] if bundle else asset['path']
|
|
134
|
+
path = inside(root, entrypoint)
|
|
135
|
+
document, binary = read_document(path, asset['type'], include_bin_location=True)
|
|
136
|
+
resources(document, entrypoint, binary['bytes'] if binary is not None else None)
|
|
137
|
+
textures = document.get('textures', [])
|
|
138
|
+
if not isinstance(textures, list):
|
|
139
|
+
invalid('glTF textures must be an array')
|
|
140
|
+
selected = set()
|
|
141
|
+
sources = []
|
|
142
|
+
for texture in textures:
|
|
143
|
+
if not isinstance(texture, dict):
|
|
144
|
+
invalid('invalid glTF texture declaration')
|
|
145
|
+
source = texture.get('source')
|
|
146
|
+
if source is None:
|
|
147
|
+
extensions = texture.get('extensions') or {}
|
|
148
|
+
webp = extensions.get('EXT_texture_webp') if isinstance(extensions, dict) else None
|
|
149
|
+
source = webp.get('source') if isinstance(webp, dict) else None
|
|
150
|
+
if source is not None:
|
|
151
|
+
source = integer(source)
|
|
152
|
+
selected.add(source)
|
|
153
|
+
sources.append(source)
|
|
154
|
+
roles = {source: set() for source in selected}
|
|
155
|
+
baked = set()
|
|
156
|
+
for texture_index, role in texture_uses(document):
|
|
157
|
+
indexed(textures, texture_index)
|
|
158
|
+
source = sources[texture_index]
|
|
159
|
+
if source is None:
|
|
160
|
+
continue
|
|
161
|
+
if role == 'baked':
|
|
162
|
+
baked.add(source)
|
|
163
|
+
else:
|
|
164
|
+
roles[source].add(role)
|
|
165
|
+
facts = []
|
|
166
|
+
total, allocations = 0, 0
|
|
167
|
+
for index in sorted(selected):
|
|
168
|
+
image = indexed(document.get('images'), index)
|
|
169
|
+
value = image_facts(root, entrypoint, path, document, binary, image)
|
|
170
|
+
if value['format'] not in ('png', 'jpeg', 'webp'):
|
|
171
|
+
invalid('glTF textures require PNG, JPEG or static WebP bytes')
|
|
172
|
+
declared = image.get('mimeType')
|
|
173
|
+
if declared is not None and declared != {'png': 'image/png', 'jpeg': 'image/jpeg', 'webp': 'image/webp'}[value['format']]:
|
|
174
|
+
invalid('image media type disagrees with its inspected bytes')
|
|
175
|
+
count = max(1, len(roles[index] & {'color', 'data'})) + int(index in baked)
|
|
176
|
+
total += value['decodedBytes'] * count
|
|
177
|
+
allocations += count
|
|
178
|
+
if total > MAX_IMAGE_DECODE_BYTES:
|
|
179
|
+
invalid('imported images exceed the 1 GiB decoded pixel budget')
|
|
180
|
+
facts.append({'imageIndex': index, 'allocations': count, 'roles': sorted(roles[index]), **value})
|
|
181
|
+
return {'images': len(facts), 'allocations': allocations, 'decodedBytes': total, 'facts': facts}
|
|
182
|
+
except (OSError, ValueError, UnicodeError, binascii.Error) as error:
|
|
183
|
+
raise ActionError('ASSET_CONTENT_MISMATCH', 'cannot inspect imported image before decoding: %s' % error)
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Preserve native glTF color/data image uses without changing importer files."""
|
|
2
|
+
import bpy
|
|
3
|
+
from deepblend_image_roles import SOURCE_SPACE
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class ImageRoles:
|
|
7
|
+
is_critical = True
|
|
8
|
+
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self.images = {}
|
|
11
|
+
self.rgb_roles = {}
|
|
12
|
+
|
|
13
|
+
def gather_import_gltf_before_hook(self, gltf):
|
|
14
|
+
from types import SimpleNamespace
|
|
15
|
+
from io_scene_gltf2.blender.imp.texture import get_source
|
|
16
|
+
from deepblend_gltf_roles import texture_uses
|
|
17
|
+
document = {'materials': [material.to_dict() for material in (gltf.data.materials or [])]}
|
|
18
|
+
for index, role in texture_uses(document):
|
|
19
|
+
if role not in ('color', 'data'):
|
|
20
|
+
continue
|
|
21
|
+
source = get_source(SimpleNamespace(gltf=gltf), gltf.data.textures[index])
|
|
22
|
+
if source is not None:
|
|
23
|
+
self.rgb_roles.setdefault(source, set()).add(role)
|
|
24
|
+
|
|
25
|
+
def gather_import_texture_before_hook(self, texture, mh, tex_info, location,
|
|
26
|
+
label, color_socket, alpha_socket, is_data, gltf):
|
|
27
|
+
from io_scene_gltf2.blender.imp.image import BlenderImage
|
|
28
|
+
from io_scene_gltf2.blender.imp.texture import get_source
|
|
29
|
+
source = get_source(mh, texture)
|
|
30
|
+
if source is None:
|
|
31
|
+
return
|
|
32
|
+
BlenderImage.create(gltf, source)
|
|
33
|
+
declaration = gltf.data.images[source]
|
|
34
|
+
if not declaration.blender_image_name:
|
|
35
|
+
return
|
|
36
|
+
image = bpy.data.images[declaration.blender_image_name]
|
|
37
|
+
# The native hook supplies semantic use, independent of node labels or
|
|
38
|
+
# later graph rewrites. Alpha alone has the same values in either space.
|
|
39
|
+
role = 'alpha' if color_socket is None and alpha_socket is not None else 'data' if is_data else 'color'
|
|
40
|
+
if role == 'alpha' and source in self.rgb_roles:
|
|
41
|
+
# If alpha is imported first, use a declared RGB interpretation.
|
|
42
|
+
# Otherwise a later RGB use would create an unnecessary copy which
|
|
43
|
+
# the alpha-only budget correctly does not include.
|
|
44
|
+
roles = self.rgb_roles[source]
|
|
45
|
+
role = next(iter(roles)) if len(roles) == 1 else 'data' if image.colorspace_settings.name == 'Non-Color' else 'color'
|
|
46
|
+
key = (source, role)
|
|
47
|
+
if key not in self.images:
|
|
48
|
+
if role != 'alpha':
|
|
49
|
+
native_space = image.get(SOURCE_SPACE, image.colorspace_settings.name)
|
|
50
|
+
space = 'Non-Color' if role == 'data' else 'sRGB'
|
|
51
|
+
if image.colorspace_settings.name != space:
|
|
52
|
+
# External files may reuse an image from another material,
|
|
53
|
+
# image index or imported instance. Protect its existing users.
|
|
54
|
+
if image.users:
|
|
55
|
+
image = image.copy()
|
|
56
|
+
image[SOURCE_SPACE] = native_space
|
|
57
|
+
image.colorspace_settings.name = space
|
|
58
|
+
self.images[key] = image
|
|
59
|
+
declaration.blender_image_name = self.images[key].name
|
|
60
|
+
|
|
61
|
+
def gather_import_texture_after_hook(self, texture, tree, mh, tex_info, location,
|
|
62
|
+
label, color_socket, alpha_socket, is_data, gltf):
|
|
63
|
+
from io_scene_gltf2.blender.imp.texture import get_source
|
|
64
|
+
source = get_source(mh, texture)
|
|
65
|
+
if source is None:
|
|
66
|
+
return
|
|
67
|
+
# Restore a color interpretation for native specular/glossiness baking,
|
|
68
|
+
# which reads the image cache directly outside the texture-node hook.
|
|
69
|
+
for role in ('color', 'data', 'alpha'):
|
|
70
|
+
if (source, role) in self.images:
|
|
71
|
+
gltf.data.images[source].blender_image_name = self.images[source, role].name
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def import_gltf(path):
|
|
76
|
+
"""Run the registered operator with one critical extension for this call.
|
|
77
|
+
|
|
78
|
+
The pinned operator exposes user hooks through its importer constructor.
|
|
79
|
+
Attach ours in memory for the synchronous call and restore that constructor
|
|
80
|
+
on success and failure. Keep native settings, geometry, samplers, UVs and
|
|
81
|
+
material conversion; no addon file or user preference is changed.
|
|
82
|
+
"""
|
|
83
|
+
from io_scene_gltf2.io.imp.gltf2_io_gltf import glTFImporter
|
|
84
|
+
original = glTFImporter.__init__
|
|
85
|
+
|
|
86
|
+
def initialize(self, filename, settings):
|
|
87
|
+
options = dict(settings)
|
|
88
|
+
options['import_user_extensions'] = [*settings['import_user_extensions'], ImageRoles()]
|
|
89
|
+
original(self, filename, options)
|
|
90
|
+
|
|
91
|
+
glTFImporter.__init__ = initialize
|
|
92
|
+
try:
|
|
93
|
+
return bpy.ops.import_scene.gltf(filepath=path)
|
|
94
|
+
finally:
|
|
95
|
+
glTFImporter.__init__ = original
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Declared image uses supported by the pinned native glTF material importer."""
|
|
2
|
+
|
|
3
|
+
CORE = {
|
|
4
|
+
'baseColorTexture': 'color', 'metallicRoughnessTexture': 'data',
|
|
5
|
+
}
|
|
6
|
+
MATERIAL = {
|
|
7
|
+
'emissiveTexture': 'color', 'normalTexture': 'data', 'occlusionTexture': 'data',
|
|
8
|
+
}
|
|
9
|
+
EXTENSIONS = {
|
|
10
|
+
'KHR_materials_pbrSpecularGlossiness': {
|
|
11
|
+
'diffuseTexture': 'color', 'specularGlossinessTexture': 'color',
|
|
12
|
+
},
|
|
13
|
+
'KHR_materials_clearcoat': {
|
|
14
|
+
'clearcoatTexture': 'data', 'clearcoatRoughnessTexture': 'data',
|
|
15
|
+
'clearcoatNormalTexture': 'data',
|
|
16
|
+
},
|
|
17
|
+
'KHR_materials_transmission': {'transmissionTexture': 'data'},
|
|
18
|
+
'KHR_materials_volume': {'thicknessTexture': 'data'},
|
|
19
|
+
'KHR_materials_specular': {'specularColorTexture': 'color', 'specularTexture': 'alpha'},
|
|
20
|
+
'KHR_materials_sheen': {'sheenColorTexture': 'color', 'sheenRoughnessTexture': 'alpha'},
|
|
21
|
+
'KHR_materials_iridescence': {'iridescenceTexture': 'data', 'iridescenceThicknessTexture': 'data'},
|
|
22
|
+
'KHR_materials_anisotropy': {'anisotropyTexture': 'data'},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def texture_uses(document):
|
|
27
|
+
"""Yield texture indices and roles, including native baked roughness copies.
|
|
28
|
+
|
|
29
|
+
Alpha is linear in either image color space and needs no independent RGB
|
|
30
|
+
interpretation. Count declared uses conservatively, even unused materials.
|
|
31
|
+
Unknown material extensions remain subject to native support checks.
|
|
32
|
+
"""
|
|
33
|
+
materials = document.get('materials', [])
|
|
34
|
+
if not isinstance(materials, list):
|
|
35
|
+
raise ValueError('glTF materials must be an array')
|
|
36
|
+
for material in materials:
|
|
37
|
+
if not isinstance(material, dict):
|
|
38
|
+
raise ValueError('invalid glTF material declaration')
|
|
39
|
+
groups = [(material, MATERIAL), (material.get('pbrMetallicRoughness') or {}, CORE)]
|
|
40
|
+
extensions = material.get('extensions') or {}
|
|
41
|
+
if not isinstance(extensions, dict):
|
|
42
|
+
raise ValueError('invalid glTF material extensions')
|
|
43
|
+
groups.extend((extensions.get(name) or {}, fields) for name, fields in EXTENSIONS.items())
|
|
44
|
+
for group, fields in groups:
|
|
45
|
+
if not isinstance(group, dict):
|
|
46
|
+
raise ValueError('invalid glTF material texture group')
|
|
47
|
+
for field, role in fields.items():
|
|
48
|
+
info = group.get(field)
|
|
49
|
+
if info is not None:
|
|
50
|
+
if not isinstance(info, dict):
|
|
51
|
+
raise ValueError('invalid glTF material texture reference')
|
|
52
|
+
yield info.get('index'), role
|
|
53
|
+
specular = extensions.get('KHR_materials_pbrSpecularGlossiness') or {}
|
|
54
|
+
info = specular.get('specularGlossinessTexture')
|
|
55
|
+
if info is not None and specular.get('glossinessFactor', 1) != 0:
|
|
56
|
+
yield info.get('index'), 'baked'
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"""Bounded image metadata inspection before Blender allocates decoded pixels.
|
|
2
|
+
|
|
3
|
+
This is a resource gate, not a replacement for the native decoder. Dimensions
|
|
4
|
+
come from actual PNG/JPEG/Radiance/OpenEXR headers, never a filename or asset
|
|
5
|
+
declaration. Pixel payloads are not read here.
|
|
6
|
+
"""
|
|
7
|
+
import io
|
|
8
|
+
import re
|
|
9
|
+
import struct
|
|
10
|
+
import zlib
|
|
11
|
+
|
|
12
|
+
from deepblend_util import ActionError
|
|
13
|
+
|
|
14
|
+
MAX_IMAGE_SIDE = 8192
|
|
15
|
+
MAX_IMAGE_HEADER_BYTES = 1024 * 1024
|
|
16
|
+
MAX_IMAGE_DECODE_BYTES = 1024 * 1024 * 1024
|
|
17
|
+
MAX_IMAGE_PARTS = 256
|
|
18
|
+
MAX_IMAGE_CHANNELS = 64
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HeaderReader:
|
|
22
|
+
def __init__(self, source, limit=MAX_IMAGE_HEADER_BYTES):
|
|
23
|
+
self.source = source
|
|
24
|
+
self.limit = limit
|
|
25
|
+
self.consumed = 0
|
|
26
|
+
|
|
27
|
+
def read(self, count):
|
|
28
|
+
if count < 0 or self.consumed + count > self.limit:
|
|
29
|
+
raise ValueError('image header exceeds the 1 MiB metadata budget')
|
|
30
|
+
data = self.source.read(count)
|
|
31
|
+
self.consumed += len(data)
|
|
32
|
+
if len(data) != count:
|
|
33
|
+
raise ValueError('truncated image header')
|
|
34
|
+
return data
|
|
35
|
+
|
|
36
|
+
def string(self, maximum=255):
|
|
37
|
+
result = bytearray()
|
|
38
|
+
for _ in range(maximum + 1):
|
|
39
|
+
byte = self.read(1)
|
|
40
|
+
if byte == b'\0':
|
|
41
|
+
return bytes(result)
|
|
42
|
+
result.extend(byte)
|
|
43
|
+
raise ValueError('image header name exceeds its format limit')
|
|
44
|
+
|
|
45
|
+
def line(self):
|
|
46
|
+
result = bytearray()
|
|
47
|
+
while True:
|
|
48
|
+
byte = self.read(1)
|
|
49
|
+
if byte == b'\n':
|
|
50
|
+
return bytes(result).rstrip(b'\r')
|
|
51
|
+
result.extend(byte)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def dimensions(width, height, channels=4, tile_mode=0):
|
|
55
|
+
if not (1 <= width <= MAX_IMAGE_SIDE and 1 <= height <= MAX_IMAGE_SIDE):
|
|
56
|
+
raise ValueError('image dimensions must be between 1 and 8192 pixels')
|
|
57
|
+
# Float RGBA is a conservative common Blender buffer, including PNG/JPEG.
|
|
58
|
+
# EXR can allocate additional channels and complete mip/ripmap pyramids.
|
|
59
|
+
pixels = width * height
|
|
60
|
+
level = tile_mode & 0xf
|
|
61
|
+
rounding = 1 if tile_mode & 0x10 else 0
|
|
62
|
+
if level == 1:
|
|
63
|
+
w, h = width, height
|
|
64
|
+
while w > 1 or h > 1:
|
|
65
|
+
w, h = max(1, (w + rounding) // 2), max(1, (h + rounding) // 2)
|
|
66
|
+
pixels += w * h
|
|
67
|
+
elif level == 2:
|
|
68
|
+
def total_axis(size):
|
|
69
|
+
total = size
|
|
70
|
+
while size > 1:
|
|
71
|
+
size = max(1, (size + rounding) // 2)
|
|
72
|
+
total += size
|
|
73
|
+
return total
|
|
74
|
+
pixels = total_axis(width) * total_axis(height)
|
|
75
|
+
decoded = pixels * max(4, channels) * 4
|
|
76
|
+
if decoded > MAX_IMAGE_DECODE_BYTES:
|
|
77
|
+
raise ValueError('image exceeds the 1 GiB decoded pixel budget')
|
|
78
|
+
return {'width': width, 'height': height, 'channels': channels,
|
|
79
|
+
'decodedBytes': decoded}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def png_header(reader):
|
|
83
|
+
data = reader.read(25)
|
|
84
|
+
if data[:8] != b'\x00\x00\x00\rIHDR':
|
|
85
|
+
raise ValueError('PNG requires a first 13-byte IHDR')
|
|
86
|
+
header = data[8:21]
|
|
87
|
+
if zlib.crc32(b'IHDR' + header) & 0xffffffff != struct.unpack('>I', data[21:])[0]:
|
|
88
|
+
raise ValueError('PNG IHDR checksum does not match')
|
|
89
|
+
width, height, depth, color, compression, filtering, interlace = struct.unpack('>IIBBBBB', header)
|
|
90
|
+
depths = {0: (1, 2, 4, 8, 16), 2: (8, 16), 3: (1, 2, 4, 8), 4: (8, 16), 6: (8, 16)}
|
|
91
|
+
if depth not in depths.get(color, ()) or compression or filtering or interlace not in (0, 1):
|
|
92
|
+
raise ValueError('invalid PNG header encoding')
|
|
93
|
+
return [dimensions(width, height)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def jpeg_header(reader):
|
|
97
|
+
frames = {0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf}
|
|
98
|
+
for _ in range(4096):
|
|
99
|
+
if reader.read(1) != b'\xff':
|
|
100
|
+
raise ValueError('invalid JPEG marker')
|
|
101
|
+
marker = reader.read(1)[0]
|
|
102
|
+
while marker == 0xff:
|
|
103
|
+
marker = reader.read(1)[0]
|
|
104
|
+
if marker in (0x00, 0xd8, 0xd9, 0xda):
|
|
105
|
+
raise ValueError('JPEG has no bounded frame dimensions before pixel data')
|
|
106
|
+
if marker == 0x01 or 0xd0 <= marker <= 0xd7:
|
|
107
|
+
continue
|
|
108
|
+
size = struct.unpack('>H', reader.read(2))[0]
|
|
109
|
+
if size < 2:
|
|
110
|
+
raise ValueError('invalid JPEG segment length')
|
|
111
|
+
data = reader.read(size - 2)
|
|
112
|
+
if marker in frames:
|
|
113
|
+
if len(data) < 6:
|
|
114
|
+
raise ValueError('truncated JPEG frame')
|
|
115
|
+
height, width = struct.unpack('>HH', data[1:5])
|
|
116
|
+
channels = data[5]
|
|
117
|
+
if not 1 <= channels <= 4 or size != 8 + 3 * channels:
|
|
118
|
+
raise ValueError('invalid JPEG frame component count')
|
|
119
|
+
return [dimensions(width, height, channels)]
|
|
120
|
+
raise ValueError('JPEG exceeds the marker count budget')
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def hdr_header(reader, prefix):
|
|
124
|
+
first = prefix + reader.line()
|
|
125
|
+
if first not in (b'#?RADIANCE', b'#?RGBE'):
|
|
126
|
+
raise ValueError('invalid Radiance image signature')
|
|
127
|
+
found = False
|
|
128
|
+
for _ in range(4096):
|
|
129
|
+
line = reader.line()
|
|
130
|
+
if not line:
|
|
131
|
+
break
|
|
132
|
+
if line.startswith(b'FORMAT='):
|
|
133
|
+
if found or line not in (b'FORMAT=32-bit_rle_rgbe', b'FORMAT=32-bit_rle_xyze'):
|
|
134
|
+
raise ValueError('invalid Radiance image encoding')
|
|
135
|
+
found = True
|
|
136
|
+
else:
|
|
137
|
+
raise ValueError('Radiance exceeds the header line budget')
|
|
138
|
+
resolution = re.fullmatch(rb'([+-])([XY])\s+(\d+)\s+([+-])([XY])\s+(\d+)', reader.line())
|
|
139
|
+
if not found or not resolution or resolution[2] == resolution[5]:
|
|
140
|
+
raise ValueError('Radiance requires two distinct resolution axes')
|
|
141
|
+
axes = {resolution[2]: int(resolution[3]), resolution[5]: int(resolution[6])}
|
|
142
|
+
return [dimensions(axes[b'X'], axes[b'Y'])]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def webp_header(reader):
|
|
146
|
+
size, magic = struct.unpack('<I4s', reader.read(8))
|
|
147
|
+
if magic != b'WEBP' or size < 12:
|
|
148
|
+
raise ValueError('invalid WebP RIFF container')
|
|
149
|
+
remaining = size - 4
|
|
150
|
+
for _ in range(4096):
|
|
151
|
+
if remaining < 8:
|
|
152
|
+
raise ValueError('WebP has no bounded image header')
|
|
153
|
+
kind, length = struct.unpack('<4sI', reader.read(8))
|
|
154
|
+
padded = length + length % 2
|
|
155
|
+
remaining -= 8
|
|
156
|
+
if padded > remaining:
|
|
157
|
+
raise ValueError('WebP chunk escapes its RIFF container')
|
|
158
|
+
if kind == b'VP8X':
|
|
159
|
+
if length != 10:
|
|
160
|
+
raise ValueError('invalid WebP extended image header')
|
|
161
|
+
data = reader.read(10)
|
|
162
|
+
if data[0] & 0xc3 or data[1:4] != b'\0\0\0':
|
|
163
|
+
raise ValueError('animated or reserved WebP features are not static textures')
|
|
164
|
+
return [dimensions(1 + int.from_bytes(data[4:7], 'little'),
|
|
165
|
+
1 + int.from_bytes(data[7:10], 'little'))]
|
|
166
|
+
if kind == b'VP8L':
|
|
167
|
+
if length < 5:
|
|
168
|
+
raise ValueError('truncated WebP lossless header')
|
|
169
|
+
data = reader.read(5)
|
|
170
|
+
bits = int.from_bytes(data[1:], 'little')
|
|
171
|
+
if data[0] != 0x2f or bits >> 29:
|
|
172
|
+
raise ValueError('invalid WebP lossless signature or version')
|
|
173
|
+
return [dimensions(1 + (bits & 0x3fff), 1 + ((bits >> 14) & 0x3fff))]
|
|
174
|
+
if kind == b'VP8 ':
|
|
175
|
+
if length < 10:
|
|
176
|
+
raise ValueError('truncated WebP lossy header')
|
|
177
|
+
data = reader.read(10)
|
|
178
|
+
frame = int.from_bytes(data[:3], 'little')
|
|
179
|
+
if frame & 1 or (frame >> 1) & 7 > 3 or not frame & 0x10 or data[3:6] != b'\x9d\x01\x2a':
|
|
180
|
+
raise ValueError('WebP requires a visible VP8 key frame')
|
|
181
|
+
width, height = struct.unpack('<HH', data[6:10])
|
|
182
|
+
return [dimensions(width & 0x3fff, height & 0x3fff)]
|
|
183
|
+
reader.read(padded)
|
|
184
|
+
remaining -= padded
|
|
185
|
+
raise ValueError('WebP exceeds the chunk count budget')
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def exr_header(reader):
|
|
189
|
+
version = struct.unpack('<I', reader.read(4))[0]
|
|
190
|
+
if version & 0xff not in (1, 2) or version & ~0x1eFF:
|
|
191
|
+
raise ValueError('unsupported OpenEXR version flags')
|
|
192
|
+
if version & 0x800:
|
|
193
|
+
raise ValueError('deep OpenEXR sample allocation cannot be bounded from dimensions')
|
|
194
|
+
maximum = 255 if version & 0x400 else 31
|
|
195
|
+
multipart = bool(version & 0x1000)
|
|
196
|
+
parts = []
|
|
197
|
+
while True:
|
|
198
|
+
attributes = {}
|
|
199
|
+
for _ in range(4096):
|
|
200
|
+
name = reader.string(maximum)
|
|
201
|
+
if not name:
|
|
202
|
+
break
|
|
203
|
+
kind = reader.string(maximum)
|
|
204
|
+
if not kind or name in attributes:
|
|
205
|
+
raise ValueError('empty or duplicate OpenEXR attribute')
|
|
206
|
+
size = struct.unpack('<i', reader.read(4))[0]
|
|
207
|
+
attributes[name] = (kind, reader.read(size))
|
|
208
|
+
else:
|
|
209
|
+
raise ValueError('OpenEXR exceeds the attribute count budget')
|
|
210
|
+
if not attributes:
|
|
211
|
+
if not multipart or not parts:
|
|
212
|
+
raise ValueError('OpenEXR has no image header')
|
|
213
|
+
return parts
|
|
214
|
+
if len(parts) >= MAX_IMAGE_PARTS:
|
|
215
|
+
raise ValueError('OpenEXR exceeds the 256-part budget')
|
|
216
|
+
for kind, value in attributes.values():
|
|
217
|
+
if kind == b'preview':
|
|
218
|
+
if len(value) < 8:
|
|
219
|
+
raise ValueError('truncated OpenEXR preview header')
|
|
220
|
+
preview_width, preview_height = struct.unpack('<II', value[:8])
|
|
221
|
+
dimensions(preview_width, preview_height)
|
|
222
|
+
if len(value) != 8 + preview_width * preview_height * 4:
|
|
223
|
+
raise ValueError('OpenEXR preview size disagrees with its payload')
|
|
224
|
+
window = attributes.get(b'dataWindow')
|
|
225
|
+
channels = attributes.get(b'channels')
|
|
226
|
+
if not window or window[0] != b'box2i' or len(window[1]) != 16 or not channels or channels[0] != b'chlist':
|
|
227
|
+
raise ValueError('OpenEXR requires dataWindow and channel metadata')
|
|
228
|
+
xmin, ymin, xmax, ymax = struct.unpack('<iiii', window[1])
|
|
229
|
+
channel_reader = HeaderReader(io.BytesIO(channels[1]))
|
|
230
|
+
names = set()
|
|
231
|
+
while True:
|
|
232
|
+
name = channel_reader.string(maximum)
|
|
233
|
+
if not name:
|
|
234
|
+
break
|
|
235
|
+
pixel_type, linear, reserved, xs, ys = struct.unpack('<iB3sii', channel_reader.read(16))
|
|
236
|
+
if name in names or len(names) >= MAX_IMAGE_CHANNELS or pixel_type not in (0, 1, 2) or linear not in (0, 1) or reserved != b'\0\0\0' or xs < 1 or ys < 1:
|
|
237
|
+
raise ValueError('invalid or excessive OpenEXR channels')
|
|
238
|
+
names.add(name)
|
|
239
|
+
if not names or channel_reader.consumed != len(channels[1]):
|
|
240
|
+
raise ValueError('invalid OpenEXR channel list')
|
|
241
|
+
kind = attributes.get(b'type')
|
|
242
|
+
if kind and (kind[0] != b'string' or kind[1] not in (b'scanlineimage', b'tiledimage')):
|
|
243
|
+
raise ValueError('unsupported OpenEXR part type')
|
|
244
|
+
tile_mode = 0
|
|
245
|
+
tiles = attributes.get(b'tiles')
|
|
246
|
+
if tiles:
|
|
247
|
+
if tiles[0] != b'tiledesc' or len(tiles[1]) != 9:
|
|
248
|
+
raise ValueError('invalid OpenEXR tile descriptor')
|
|
249
|
+
tile_width, tile_height, tile_mode = struct.unpack('<IIB', tiles[1])
|
|
250
|
+
if not tile_width or not tile_height or tile_mode not in (0, 1, 2, 16, 17, 18):
|
|
251
|
+
raise ValueError('invalid OpenEXR tile descriptor')
|
|
252
|
+
part = dimensions(xmax - xmin + 1, ymax - ymin + 1, len(names), tile_mode)
|
|
253
|
+
display = attributes.get(b'displayWindow')
|
|
254
|
+
if display:
|
|
255
|
+
if display[0] != b'box2i' or len(display[1]) != 16:
|
|
256
|
+
raise ValueError('invalid OpenEXR display window')
|
|
257
|
+
dxmin, dymin, dxmax, dymax = struct.unpack('<iiii', display[1])
|
|
258
|
+
display_facts = dimensions(dxmax - dxmin + 1, dymax - dymin + 1, len(names), tile_mode)
|
|
259
|
+
part['decodedBytes'] = max(part['decodedBytes'], display_facts['decodedBytes'])
|
|
260
|
+
parts.append(part)
|
|
261
|
+
if sum(p['decodedBytes'] for p in parts) > MAX_IMAGE_DECODE_BYTES:
|
|
262
|
+
raise ValueError('OpenEXR parts exceed the 1 GiB decoded pixel budget')
|
|
263
|
+
if not multipart:
|
|
264
|
+
return parts
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def inspect_image_header(source):
|
|
268
|
+
"""Read a seek-free bounded header from an already open binary stream."""
|
|
269
|
+
reader = HeaderReader(source)
|
|
270
|
+
prefix = reader.read(2)
|
|
271
|
+
if prefix == b'\xff\xd8':
|
|
272
|
+
format_name, parts = 'jpeg', jpeg_header(reader)
|
|
273
|
+
elif prefix == b'#?':
|
|
274
|
+
format_name, parts = 'hdr', hdr_header(reader, prefix)
|
|
275
|
+
else:
|
|
276
|
+
magic = prefix + reader.read(2)
|
|
277
|
+
if magic == b'\x89PNG' and reader.read(4) == b'\r\n\x1a\n':
|
|
278
|
+
format_name, parts = 'png', png_header(reader)
|
|
279
|
+
elif magic == b'v/1\x01':
|
|
280
|
+
format_name, parts = 'exr', exr_header(reader)
|
|
281
|
+
elif magic == b'RIFF':
|
|
282
|
+
format_name, parts = 'webp', webp_header(reader)
|
|
283
|
+
else:
|
|
284
|
+
raise ValueError('unsupported image header signature')
|
|
285
|
+
return {'format': format_name, 'parts': parts, 'headerBytes': reader.consumed,
|
|
286
|
+
'decodedBytes': sum(p['decodedBytes'] for p in parts)}
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def check_image_header(path):
|
|
290
|
+
try:
|
|
291
|
+
with open(path, 'rb') as source:
|
|
292
|
+
return inspect_image_header(source)
|
|
293
|
+
except (OSError, ValueError, struct.error) as error:
|
|
294
|
+
raise ActionError('ASSET_CONTENT_MISMATCH', 'cannot inspect image asset before decoding: %s' % error)
|