#![warn(missing_docs)]
mod capability;
pub mod fix;
mod scale;
pub mod write;
pub use capability::{
GltfAccessorCapability, GltfAnimationChannelCapability, GltfAttributeCapability,
GltfBufferCapability, GltfBufferSourceKind, GltfBufferViewCapability, GltfCapabilityManifest,
GltfCapabilityViolation, GltfCapabilityViolationKind, GltfContainerKind,
GltfInstancingCapability, GltfNodeCapability, GltfNodeRestKind, GltfPrimitiveCapability,
GltfScalePreflightError, GltfScaleSource, GltfSkinCapability, preflight_scale_source,
preflight_scale_source_bytes,
};
pub use scale::{
GltfRawJsonDifference, GltfRawJsonDifferenceKind, GltfRawJsonDifferenceSummary,
GltfScaleArtifact, GltfScaleArtifactProof, GltfScaleRewriteError, capability_facts,
prove_rewritten_artifact, prove_rewritten_rest_bind, rewrite_linear_units, rewrite_rest_bind,
rewrite_scale_plan,
};
use animsmith_core::model::{
AdditionalInfluenceSet, Bone, Clip, DecodedImageColorType, Document, ImageContainerFormat,
ImageSourceKind, ImageUnavailableReason, Interpolation, MaterialAsset, MaterialResourceAssets,
MaterialResourceCoverage, MaterialTextureSlot, MeshAsset, MeshInstance, NormalTextureAsset,
OcclusionTextureAsset, Primitive, Property, SceneAsset, SceneAssets, Skeleton,
SourceImageAsset, SourceImageInspection, SourceInfo, SourceInverseBindAccessor,
SourceInverseBindAccessorStatus, SourceMaterialAsset, SourceMaterialTextureBinding,
SourceNodeAsset, SourceNodeLocalRest, SourceSkeletonAssets, SourceSkeletonCoverage,
SourceSkinAsset, SourceSkinAttachment, SourceTextureAsset, TextureAsset, Track, TrackValues,
Transform,
};
use base64::Engine as _;
use glam::{Mat4, Quat, Vec3};
use gltf::accessor::{DataType as ComponentType, Dimensions as AccessorType};
use image::{ColorType, ImageError, ImageFormat, ImageReader, Limits};
use std::collections::{BTreeMap, BTreeSet};
use std::io::{Cursor, Read};
use std::path::{Component, Path, PathBuf};
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LoadError {
#[error("failed to read {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("glTF parse error: {0}")]
Gltf(#[from] gltf::Error),
#[error("buffer resolution failed: {0}")]
Buffer(String),
#[error("malformed animation data: {0}")]
Malformed(String),
#[error("malformed node graph: {0}")]
Topology(String),
#[error(
"mesh {mesh} primitive {primitive} {attribute}: accessor {accessor} is {found}, but the loader reads {expected}"
)]
PrimitiveEncoding {
mesh: usize,
primitive: usize,
attribute: String,
accessor: usize,
found: String,
expected: String,
},
#[error("mesh {mesh} primitive {primitive} {attribute}: accessor {accessor} {problem}")]
PrimitiveAccessorLayout {
mesh: usize,
primitive: usize,
attribute: String,
accessor: usize,
problem: String,
},
#[error("clip '{clip}' node {node} sampler {slot}: accessor {accessor} {problem}")]
AnimationAccessorLayout {
clip: String,
node: usize,
slot: &'static str,
accessor: usize,
problem: String,
},
#[error(
"animation {animation} sampler {sampler} {slot} for node {node} {property}: accessor {accessor} is {found}, but the loader reads {expected}"
)]
AnimationEncoding {
animation: usize,
sampler: usize,
slot: &'static str,
node: usize,
property: &'static str,
accessor: usize,
found: String,
expected: String,
},
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum FixError {
#[error(transparent)]
Load(#[from] LoadError),
#[error(transparent)]
Write(#[from] WriteError),
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum WriteError {
#[error("failed to write {path}: {source}")]
Io {
path: String,
source: std::io::Error,
},
#[error("failed to serialize glTF JSON: {0}")]
Serialize(#[from] serde_json::Error),
#[error(
"GLB too large: {field} is {bytes} bytes, exceeding the 4 GiB limit of a GLB u32 length field"
)]
TooLarge {
field: &'static str,
bytes: usize,
},
}
pub(crate) fn safe_external_buffer_path(uri: &str) -> Result<PathBuf, LoadError> {
let decoded = decode_external_uri_path(uri)?;
if decoded.is_empty() || decoded.contains('\\') {
return Err(LoadError::Buffer(format!(
"unsafe external buffer URI {uri:?}: expected a relative child path"
)));
}
let path = Path::new(&decoded);
if path.is_absolute() {
return Err(LoadError::Buffer(format!(
"unsafe external buffer URI {uri:?}: absolute paths are not supported"
)));
}
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::Normal(part) => out.push(part),
_ => {
return Err(LoadError::Buffer(format!(
"unsafe external buffer URI {uri:?}: expected a relative child path"
)));
}
}
}
if out.as_os_str().is_empty() {
return Err(LoadError::Buffer(format!(
"unsafe external buffer URI {uri:?}: expected a relative child path"
)));
}
Ok(out)
}
fn decode_external_uri_path(uri: &str) -> Result<String, LoadError> {
let bytes = uri.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] != b'%' {
decoded.push(bytes[index]);
index += 1;
continue;
}
let Some((&high, &low)) = bytes.get(index + 1).zip(bytes.get(index + 2)) else {
return Err(unsafe_external_uri(uri));
};
let Some(value) = hex_value(high)
.zip(hex_value(low))
.map(|(high, low)| high << 4 | low)
else {
return Err(unsafe_external_uri(uri));
};
if matches!(value, b'/' | b'\\' | 0) {
return Err(unsafe_external_uri(uri));
}
decoded.push(value);
index += 3;
}
String::from_utf8(decoded).map_err(|_| unsafe_external_uri(uri))
}
fn hex_value(value: u8) -> Option<u8> {
match value {
b'0'..=b'9' => Some(value - b'0'),
b'a'..=b'f' => Some(value - b'a' + 10),
b'A'..=b'F' => Some(value - b'A' + 10),
_ => None,
}
}
fn unsafe_external_uri(uri: &str) -> LoadError {
LoadError::Buffer(format!(
"unsafe external buffer URI {uri:?}: expected a relative child path"
))
}
pub(crate) fn validate_glb_framing(bytes: &[u8]) -> Result<(), LoadError> {
const GLB_MAGIC: &[u8; 4] = b"glTF";
const GLB_HEADER_LEN: usize = 12;
if !bytes.starts_with(GLB_MAGIC) {
return Ok(());
}
if bytes.len() < GLB_HEADER_LEN {
return Err(LoadError::Buffer(
"truncated GLB: file ends before the 12-byte header".into(),
));
}
let declared =
u32::from_le_bytes(bytes[8..12].try_into().expect("slice has four bytes")) as usize;
if declared < GLB_HEADER_LEN || declared > bytes.len() {
return Err(LoadError::Buffer(format!(
"GLB header declares {declared} bytes but the file is {}",
bytes.len()
)));
}
Ok(())
}
pub(crate) fn validate_animation_channels(root: &gltf::json::Root) -> Result<(), LoadError> {
use gltf::json::validation::Checked;
let node_count = root.nodes.len();
for (ai, anim) in root.animations.iter().enumerate() {
for (ci, channel) in anim.channels.iter().enumerate() {
if matches!(channel.target.path, Checked::Invalid) {
return Err(LoadError::Malformed(format!(
"animation {ai} channel {ci}: unknown target path"
)));
}
if channel.target.node.value() >= node_count {
return Err(LoadError::Malformed(format!(
"animation {ai} channel {ci}: target node index {} out of range ({node_count} nodes)",
channel.target.node.value()
)));
}
}
}
Ok(())
}
pub(crate) fn validate_animations(doc: &gltf::Document) -> Result<(), LoadError> {
validate_animation_channels(doc.as_json())?;
validate_animation_accessor_encodings(doc)
}
struct ReaderEncoding {
accessor_type: AccessorType,
component_types: &'static [ComponentType],
}
const POSITION_ENCODING: ReaderEncoding = ReaderEncoding {
accessor_type: AccessorType::Vec3,
component_types: &[ComponentType::F32],
};
const NORMAL_ENCODING: ReaderEncoding = ReaderEncoding {
accessor_type: AccessorType::Vec3,
component_types: &[ComponentType::F32],
};
const TEX_COORD_ENCODING: ReaderEncoding = ReaderEncoding {
accessor_type: AccessorType::Vec2,
component_types: &[ComponentType::U8, ComponentType::U16, ComponentType::F32],
};
const JOINTS_ENCODING: ReaderEncoding = ReaderEncoding {
accessor_type: AccessorType::Vec4,
component_types: &[ComponentType::U8, ComponentType::U16],
};
const WEIGHTS_ENCODING: ReaderEncoding = ReaderEncoding {
accessor_type: AccessorType::Vec4,
component_types: &[ComponentType::U8, ComponentType::U16, ComponentType::F32],
};
const INDEX_ENCODING: ReaderEncoding = ReaderEncoding {
accessor_type: AccessorType::Scalar,
component_types: &[ComponentType::U8, ComponentType::U16, ComponentType::U32],
};
const INVERSE_BIND_ENCODING: ReaderEncoding = ReaderEncoding {
accessor_type: AccessorType::Mat4,
component_types: &[ComponentType::F32],
};
const ANIMATION_INPUT_ENCODING: ReaderEncoding = ReaderEncoding {
accessor_type: AccessorType::Scalar,
component_types: &[ComponentType::F32],
};
const ANIMATION_VEC3_OUTPUT_ENCODING: ReaderEncoding = ReaderEncoding {
accessor_type: AccessorType::Vec3,
component_types: &[ComponentType::F32],
};
const ANIMATION_ROTATION_OUTPUT_ENCODING: ReaderEncoding = ReaderEncoding {
accessor_type: AccessorType::Vec4,
component_types: &[
ComponentType::I8,
ComponentType::U8,
ComponentType::I16,
ComponentType::U16,
ComponentType::F32,
],
};
const ANIMATION_WEIGHT_OUTPUT_ENCODING: ReaderEncoding = ReaderEncoding {
accessor_type: AccessorType::Scalar,
component_types: &[
ComponentType::I8,
ComponentType::U8,
ComponentType::I16,
ComponentType::U16,
ComponentType::F32,
],
};
fn validate_animation_accessor_encodings(doc: &gltf::Document) -> Result<(), LoadError> {
for animation in doc.animations() {
for channel in animation.channels() {
let sampler = channel.sampler();
let target = channel.target();
let node = target.node().index();
let property = target.property();
check_animation_accessor_encoding(
animation.index(),
sampler.index(),
node,
animation_property_name(property),
"input",
&sampler.input(),
&ANIMATION_INPUT_ENCODING,
)?;
let output_encoding = match property {
gltf::animation::Property::Translation | gltf::animation::Property::Scale => {
&ANIMATION_VEC3_OUTPUT_ENCODING
}
gltf::animation::Property::Rotation => &ANIMATION_ROTATION_OUTPUT_ENCODING,
gltf::animation::Property::MorphTargetWeights => &ANIMATION_WEIGHT_OUTPUT_ENCODING,
};
check_animation_accessor_encoding(
animation.index(),
sampler.index(),
node,
animation_property_name(property),
"output",
&sampler.output(),
output_encoding,
)?;
}
}
Ok(())
}
fn check_animation_accessor_encoding(
animation: usize,
sampler: usize,
node: usize,
property: &'static str,
slot: &'static str,
accessor: &gltf::Accessor<'_>,
required: &ReaderEncoding,
) -> Result<(), LoadError> {
if encoding_matches(accessor, required) {
return Ok(());
}
Err(LoadError::AnimationEncoding {
animation,
sampler,
slot,
node,
property,
accessor: accessor.index(),
found: format!(
"{} of {}",
accessor_type_name(accessor.dimensions()),
component_type_name(accessor.data_type())
),
expected: describe_encoding(required),
})
}
fn animation_property_name(property: gltf::animation::Property) -> &'static str {
match property {
gltf::animation::Property::Translation => "translation",
gltf::animation::Property::Rotation => "rotation",
gltf::animation::Property::Scale => "scale",
gltf::animation::Property::MorphTargetWeights => "weights",
}
}
fn required_attribute_encoding(semantic: &gltf::Semantic) -> Option<&'static ReaderEncoding> {
match semantic {
gltf::Semantic::Positions => Some(&POSITION_ENCODING),
gltf::Semantic::Normals => Some(&NORMAL_ENCODING),
gltf::Semantic::TexCoords(0) => Some(&TEX_COORD_ENCODING),
gltf::Semantic::Joints(0) => Some(&JOINTS_ENCODING),
gltf::Semantic::Weights(0) => Some(&WEIGHTS_ENCODING),
gltf::Semantic::Tangents
| gltf::Semantic::Colors(_)
| gltf::Semantic::TexCoords(_)
| gltf::Semantic::Joints(_)
| gltf::Semantic::Weights(_) => None,
}
}
fn validate_primitive_accessors(
doc: &gltf::Document,
buffers: &[Vec<u8>],
) -> Result<(), LoadError> {
for mesh in doc.meshes() {
for primitive in mesh.primitives() {
if primitive.mode() != gltf::mesh::Mode::Triangles {
continue;
}
for (semantic, accessor) in primitive.attributes() {
let Some(required) = required_attribute_encoding(&semantic) else {
continue;
};
check_primitive_accessor(
&mesh,
&primitive,
&semantic.to_string(),
&accessor,
required,
buffers,
)?;
}
if let Some(accessor) = primitive.indices() {
check_primitive_accessor(
&mesh,
&primitive,
"indices",
&accessor,
&INDEX_ENCODING,
buffers,
)?;
}
}
}
Ok(())
}
fn check_primitive_accessor(
mesh: &gltf::Mesh<'_>,
primitive: &gltf::Primitive<'_>,
attribute: &str,
accessor: &gltf::Accessor<'_>,
required: &ReaderEncoding,
buffers: &[Vec<u8>],
) -> Result<(), LoadError> {
if !encoding_matches(accessor, required) {
return Err(LoadError::PrimitiveEncoding {
mesh: mesh.index(),
primitive: primitive.index(),
attribute: attribute.to_owned(),
accessor: accessor.index(),
found: format!(
"{} of {}",
accessor_type_name(accessor.dimensions()),
component_type_name(accessor.data_type())
),
expected: describe_encoding(required),
});
}
if let Some(problem) = unreadable_primitive_layout(accessor, buffers) {
return Err(LoadError::PrimitiveAccessorLayout {
mesh: mesh.index(),
primitive: primitive.index(),
attribute: attribute.to_owned(),
accessor: accessor.index(),
problem,
});
}
Ok(())
}
fn unreadable_primitive_layout(
accessor: &gltf::Accessor<'_>,
buffers: &[Vec<u8>],
) -> Option<String> {
unreadable_layout(accessor).or_else(|| {
if let Some(view) = accessor.view()
&& let Some(problem) = loaded_buffer_shortfall("elements", &view, buffers)
{
return Some(problem);
}
let sparse = accessor.sparse()?;
loaded_buffer_shortfall("sparse indices", &sparse.indices().view(), buffers)
.or_else(|| loaded_buffer_shortfall("sparse values", &sparse.values().view(), buffers))
})
}
fn loaded_buffer_shortfall(
subject: &str,
view: &gltf::buffer::View<'_>,
buffers: &[Vec<u8>],
) -> Option<String> {
let view_end = view_end(view)?;
let buffer_index = view.buffer().index();
let loaded_length = buffers.get(buffer_index).map_or(0, Vec::len);
(view_end > loaded_length).then(|| {
format!(
"reads its {subject} from buffer view {}, whose byte extent ends at {view_end} \
beyond loaded buffer {buffer_index}'s {loaded_length} bytes",
view.index()
)
})
}
fn check_sampler_accessor(
clip: &str,
node: usize,
slot: &'static str,
accessor: &gltf::Accessor<'_>,
) -> Result<(), LoadError> {
match unreadable_layout(accessor) {
Some(problem) => Err(LoadError::AnimationAccessorLayout {
clip: clip.to_owned(),
node,
slot,
accessor: accessor.index(),
problem,
}),
None => Ok(()),
}
}
fn unreadable_layout(accessor: &gltf::Accessor<'_>) -> Option<String> {
let size = accessor.size();
if let Some(view) = accessor.view()
&& let Some(problem) =
unwalkable("elements", &view, accessor.offset(), accessor.count(), size)
{
return Some(problem);
}
let sparse = accessor.sparse()?;
if sparse.count() == 0 {
return Some("declares a sparse block of count 0, which its reader cannot walk".to_owned());
}
let indices = sparse.indices();
let values = sparse.values();
unwalkable(
"sparse indices",
&indices.view(),
indices.offset(),
sparse.count(),
indices.index_type().size(),
)
.or_else(|| {
unwalkable(
"sparse values",
&values.view(),
values.offset(),
sparse.count(),
size,
)
})
}
fn view_end(view: &gltf::buffer::View<'_>) -> Option<usize> {
view.offset().checked_add(view.length())
}
fn unwalkable(
subject: &str,
view: &gltf::buffer::View<'_>,
offset: usize,
count: usize,
size: usize,
) -> Option<String> {
let Some(view_end) = view_end(view) else {
return Some(format!(
"reads its {subject} from buffer view {}, whose byteOffset {} plus byteLength {} \
is a byte extent that overflows",
view.index(),
view.offset(),
view.length()
));
};
if view_end > view.buffer().length() {
return Some(format!(
"reads its {subject} from buffer view {}, whose byte extent ends at {view_end} \
beyond buffer {}'s byteLength {}",
view.index(),
view.buffer().index(),
view.buffer().length()
));
}
let stride = view.stride().unwrap_or(size);
if stride < size {
return Some(format!(
"reads its {subject} from buffer view {} at byteStride {stride}, \
shorter than the {size}-byte element it strides over",
view.index()
));
}
let required_end = count
.checked_sub(1)
.and_then(|last| stride.checked_mul(last))
.and_then(|span| span.checked_add(offset))
.and_then(|end| end.checked_add(size));
if count == 0 {
return None;
}
let Some(required_end) = required_end else {
return Some(format!(
"walks {count} {subject} of {size} bytes at byteStride {stride} \
from byteOffset {offset}, a byte extent that overflows"
));
};
(required_end > view.length()).then(|| {
format!(
"walks {count} {subject} of {size} bytes at byteStride {stride} from byteOffset \
{offset}, requiring byte extent {required_end} beyond buffer view {}'s byteLength {}",
view.index(),
view.length()
)
})
}
fn inverse_bind_is_readable(accessor: &gltf::Accessor<'_>) -> bool {
encoding_matches(accessor, &INVERSE_BIND_ENCODING) && unreadable_layout(accessor).is_none()
}
fn encoding_matches(accessor: &gltf::Accessor<'_>, required: &ReaderEncoding) -> bool {
accessor.dimensions() == required.accessor_type
&& required.component_types.contains(&accessor.data_type())
}
fn describe_encoding(required: &ReaderEncoding) -> String {
let names: Vec<&str> = required
.component_types
.iter()
.copied()
.map(component_type_name)
.collect();
let components = match names.as_slice() {
[] => String::new(),
[only] => (*only).to_owned(),
[first, last] => format!("{first} or {last}"),
[rest @ .., last] => format!("{}, or {last}", rest.join(", ")),
};
format!(
"{} of {components}",
accessor_type_name(required.accessor_type)
)
}
fn accessor_type_name(accessor_type: AccessorType) -> &'static str {
match accessor_type {
AccessorType::Scalar => "SCALAR",
AccessorType::Vec2 => "VEC2",
AccessorType::Vec3 => "VEC3",
AccessorType::Vec4 => "VEC4",
AccessorType::Mat2 => "MAT2",
AccessorType::Mat3 => "MAT3",
AccessorType::Mat4 => "MAT4",
}
}
fn component_type_name(component_type: ComponentType) -> &'static str {
match component_type {
ComponentType::I8 => "BYTE",
ComponentType::U8 => "UNSIGNED_BYTE",
ComponentType::I16 => "SHORT",
ComponentType::U16 => "UNSIGNED_SHORT",
ComponentType::U32 => "UNSIGNED_INT",
ComponentType::F32 => "FLOAT",
}
}
fn validate_track_lengths(
clip: &str,
node: usize,
interpolation: Interpolation,
times: &[f32],
values: &TrackValues,
) -> Result<(), LoadError> {
if times.is_empty() {
return Err(LoadError::Malformed(format!(
"clip '{clip}' node {node}: animation channel with zero keyframes"
)));
}
let per_key = match interpolation {
Interpolation::CubicSpline => 3,
_ => 1,
};
let expected = times.len() * per_key;
let actual = match values {
TrackValues::Vec3s(v) => v.len(),
TrackValues::Quats(v) => v.len(),
};
if actual != expected {
return Err(LoadError::Malformed(format!(
"clip '{clip}' node {node}: {} keyframe times but {actual} output values (expected {expected})",
times.len()
)));
}
Ok(())
}
pub fn load(path: &Path) -> Result<Document, LoadError> {
let bytes = std::fs::read(path).map_err(|source| LoadError::Io {
path: path.display().to_string(),
source,
})?;
load_bytes(path, &bytes)
}
pub fn load_bytes(path: &Path, bytes: &[u8]) -> Result<Document, LoadError> {
validate_glb_framing(bytes)?;
let gltf = gltf::Gltf::from_slice(bytes)?;
validate_animations(&gltf.document)?;
let buffers = resolve_buffers(&gltf, path.parent())?;
validate_primitive_accessors(&gltf.document, &buffers)?;
let topo = topology(&gltf.document)?;
let source_skeleton = extract_source_skeleton(&gltf.document, &buffers, &topo);
let mut doc = build_document(&gltf, &buffers, path, &topo)?;
doc.assets = extract_assets(&gltf.document, &buffers, path.parent(), &topo.bone_of_node);
doc.assets.scenes = extract_scenes(&gltf.document, &topo.bone_of_node);
doc.assets.default_scene = gltf.document.default_scene().map(|scene| scene.index());
doc.assets.source_skeleton = source_skeleton;
Ok(doc)
}
pub(crate) fn resolve_buffers(
gltf: &gltf::Gltf,
base: Option<&Path>,
) -> Result<Vec<Vec<u8>>, LoadError> {
let mut buffers = Vec::new();
for buffer in gltf.buffers() {
let data = match buffer.source() {
gltf::buffer::Source::Bin => gltf
.blob
.clone()
.ok_or_else(|| LoadError::Buffer("GLB has no BIN chunk".into()))?,
gltf::buffer::Source::Uri(uri) => {
if let Some(encoded) = uri.strip_prefix("data:") {
let payload =
encoded
.split_once("base64,")
.map(|(_, p)| p)
.ok_or_else(|| {
LoadError::Buffer(format!(
"unsupported data URI in buffer: {uri:.40}"
))
})?;
base64::engine::general_purpose::STANDARD
.decode(payload)
.map_err(|e| LoadError::Buffer(format!("bad base64 data URI: {e}")))?
} else {
let path = base
.unwrap_or(Path::new("."))
.join(safe_external_buffer_path(uri)?);
std::fs::read(&path).map_err(|source| LoadError::Io {
path: path.display().to_string(),
source,
})?
}
}
};
buffers.push(data);
}
Ok(buffers)
}
fn build_document(
gltf: &gltf::Gltf,
buffers: &[Vec<u8>],
path: &Path,
topo: &Topology,
) -> Result<Document, LoadError> {
let doc = &gltf.document;
let nodes: Vec<gltf::Node> = doc.nodes().collect();
let Topology {
order,
parent,
bone_of_node,
} = topo;
let mut bones: Vec<Bone> = Vec::with_capacity(nodes.len());
for &node_index in order {
let node = &nodes[node_index];
let (t, r, s) = node.transform().decomposed();
bones.push(Bone {
name: node
.name()
.map(str::to_owned)
.unwrap_or_else(|| format!("node{node_index}")),
parent: parent[node_index].and_then(|p| bone_of_node[p]),
rest: Transform {
translation: Vec3::from_array(t),
rotation: Quat::from_array(r),
scale: Vec3::from_array(s),
},
inverse_bind: None,
});
}
for skin in doc.skins() {
if skin
.inverse_bind_matrices()
.is_none_or(|accessor| accessor.count() == 0 || !inverse_bind_is_readable(&accessor))
{
continue;
}
let reader = skin.reader(|buffer| buffers.get(buffer.index()).map(Vec::as_slice));
if let Some(ibms) = reader.read_inverse_bind_matrices() {
for (joint, ibm) in skin.joints().zip(ibms) {
if let Some(bone_id) = bone_of_node[joint.index()] {
bones[bone_id].inverse_bind = Some(Mat4::from_cols_array_2d(&ibm));
}
}
}
}
let mut clips = Vec::new();
let mut name_uses: BTreeMap<String, usize> = BTreeMap::new();
for animation in doc.animations() {
let base_name = animation
.name()
.map(str::to_owned)
.unwrap_or_else(|| format!("animation{}", animation.index()));
let uses = name_uses.entry(base_name.clone()).or_insert(0);
let name = if *uses == 0 {
base_name.clone()
} else {
format!("{base_name}#{uses}")
};
*uses += 1;
let mut tracks = Vec::new();
let mut duration = 0.0f64;
for channel in animation.channels() {
let Some(bone) = bone_of_node[channel.target().node().index()] else {
continue;
};
let sampler = channel.sampler();
let node = channel.target().node().index();
if sampler.input().count() == 0 || sampler.output().count() == 0 {
return Err(LoadError::Malformed(format!(
"clip '{name}' node {node}: animation channel with zero keyframes"
)));
}
check_sampler_accessor(&name, node, "input", &sampler.input())?;
check_sampler_accessor(&name, node, "output", &sampler.output())?;
let reader = channel.reader(|buffer| buffers.get(buffer.index()).map(Vec::as_slice));
let Some(times) = reader.read_inputs().map(|it| it.collect::<Vec<f32>>()) else {
continue;
};
let (property, values) = match reader.read_outputs() {
Some(gltf::animation::util::ReadOutputs::Translations(it)) => (
Property::Translation,
TrackValues::Vec3s(it.map(Vec3::from_array).collect()),
),
Some(gltf::animation::util::ReadOutputs::Rotations(r)) => (
Property::Rotation,
TrackValues::Quats(r.into_f32().map(Quat::from_array).collect()),
),
Some(gltf::animation::util::ReadOutputs::Scales(it)) => (
Property::Scale,
TrackValues::Vec3s(it.map(Vec3::from_array).collect()),
),
Some(gltf::animation::util::ReadOutputs::MorphTargetWeights(_)) | None => continue,
};
let interpolation = match channel.sampler().interpolation() {
gltf::animation::Interpolation::Linear => Interpolation::Linear,
gltf::animation::Interpolation::Step => Interpolation::Step,
gltf::animation::Interpolation::CubicSpline => Interpolation::CubicSpline,
};
validate_track_lengths(&name, node, interpolation, ×, &values)?;
duration = times
.iter()
.copied()
.filter(|time| time.is_finite())
.map(f64::from)
.fold(duration, f64::max);
tracks.push(Track {
bone,
property,
interpolation,
times,
values,
});
}
clips.push(Clip {
name,
duration_s: duration,
tracks,
});
}
Ok(Document {
skeleton: Skeleton { bones },
clips,
assets: SceneAssets::default(),
source: SourceInfo {
path: Some(path.display().to_string()),
format: Some("gltf".into()),
},
})
}
struct Topology {
order: Vec<usize>,
parent: Vec<Option<usize>>,
bone_of_node: Vec<Option<usize>>,
}
fn topology(doc: &gltf::Document) -> Result<Topology, LoadError> {
let node_count = doc.nodes().count();
let mut parent_refs: Vec<u32> = vec![0; node_count];
for node in doc.nodes() {
for child in node.children() {
let refs = &mut parent_refs[child.index()];
*refs = refs.saturating_add(1);
}
}
if let Some(dup) = parent_refs.iter().position(|&refs| refs > 1) {
return Err(LoadError::Topology(format!(
"node {dup} is a child of {} nodes; glTF requires a forest (one parent per node)",
parent_refs[dup]
)));
}
let nodes: Vec<gltf::Node> = doc.nodes().collect();
let mut order: Vec<usize> = Vec::with_capacity(node_count);
let mut parent: Vec<Option<usize>> = vec![None; node_count];
let mut stack: Vec<usize> = doc
.nodes()
.filter(|n| parent_refs[n.index()] == 0)
.map(|n| n.index())
.collect();
stack.reverse(); let mut visited: Vec<bool> = vec![false; node_count];
while let Some(i) = stack.pop() {
if visited[i] {
continue;
}
visited[i] = true;
order.push(i);
let children: Vec<usize> = nodes[i].children().map(|c| c.index()).collect();
for &c in children.iter().rev() {
parent[c] = Some(i);
stack.push(c);
}
}
if order.len() != node_count {
let orphan = (0..node_count).find(|&n| !visited[n]).unwrap();
return Err(LoadError::Topology(format!(
"node {orphan} is unreachable from any root; the node graph contains a cycle"
)));
}
let mut bone_of_node: Vec<Option<usize>> = vec![None; node_count];
for (bone_id, &node_index) in order.iter().enumerate() {
bone_of_node[node_index] = Some(bone_id);
}
Ok(Topology {
order,
parent,
bone_of_node,
})
}
fn extract_source_skeleton(
doc: &gltf::Document,
buffers: &[Vec<u8>],
topo: &Topology,
) -> SourceSkeletonAssets {
let mut scene_root_indices = vec![Vec::new(); doc.nodes().count()];
for scene in doc.scenes() {
for root in scene.nodes() {
if let Some(indices) = scene_root_indices.get_mut(root.index()) {
indices.push(scene.index());
}
}
}
for indices in &mut scene_root_indices {
indices.sort_unstable();
indices.dedup();
}
let mut attachments = vec![Vec::new(); doc.skins().count()];
for node in doc.nodes() {
let Some(skin) = node.skin() else {
continue;
};
let Some(for_skin) = attachments.get_mut(skin.index()) else {
return SourceSkeletonAssets::default();
};
for_skin.push(SourceSkinAttachment {
source_node_index: node.index(),
source_mesh_index: node.mesh().map(|mesh| mesh.index()),
});
}
let mut nodes = Vec::with_capacity(doc.nodes().count());
for node in doc.nodes() {
let local_rest = match node.transform() {
gltf::scene::Transform::Decomposed {
translation,
rotation,
scale,
} => SourceNodeLocalRest::Trs {
translation: Vec3::from_array(translation),
rotation: Quat::from_array(rotation),
scale: Vec3::from_array(scale),
},
gltf::scene::Transform::Matrix { matrix } => {
SourceNodeLocalRest::Matrix(Mat4::from_cols_array_2d(&matrix))
}
};
let mut source_node = SourceNodeAsset::new(node.index(), local_rest);
source_node.name = node.name().map(str::to_owned);
source_node.parent_source_node_index = topo.parent[node.index()];
source_node.scene_root_indices = std::mem::take(&mut scene_root_indices[node.index()]);
source_node.bone = topo.bone_of_node[node.index()];
nodes.push(source_node);
}
let mut skins = Vec::with_capacity(doc.skins().count());
for skin in doc.skins() {
let joints = skin.joints().map(|joint| joint.index()).collect::<Vec<_>>();
let skeleton_root = skin.skeleton().map(|node| node.index());
let inverse_bind_accessor = match skin.inverse_bind_matrices() {
None => SourceInverseBindAccessor::default(),
Some(accessor) if accessor.count() == 0 => SourceInverseBindAccessor {
status: SourceInverseBindAccessorStatus::EmptyAccessor,
declared_count: Some(0),
matrices: Vec::new(),
},
Some(accessor) if !inverse_bind_is_readable(&accessor) => SourceInverseBindAccessor {
status: SourceInverseBindAccessorStatus::Unreadable,
declared_count: Some(accessor.count()),
matrices: Vec::new(),
},
Some(accessor) => {
let declared_count = accessor.count();
let reader = skin.reader(|buffer| buffers.get(buffer.index()).map(Vec::as_slice));
match reader.read_inverse_bind_matrices() {
Some(matrices) => {
let matrices = matrices
.map(|matrix| Mat4::from_cols_array_2d(&matrix))
.collect::<Vec<_>>();
SourceInverseBindAccessor {
status: if matrices.len() >= joints.len() {
SourceInverseBindAccessorStatus::Available
} else {
SourceInverseBindAccessorStatus::CountMismatch
},
declared_count: Some(declared_count),
matrices,
}
}
None => SourceInverseBindAccessor {
status: SourceInverseBindAccessorStatus::Unreadable,
declared_count: Some(declared_count),
matrices: Vec::new(),
},
}
}
};
skins.push(SourceSkinAsset {
source_skin_index: skin.index(),
name: skin.name().map(str::to_owned),
skeleton_root_source_node_index: skeleton_root,
joint_source_node_indices: joints,
inverse_bind_accessor,
attachments: std::mem::take(&mut attachments[skin.index()]),
});
}
SourceSkeletonAssets {
coverage: SourceSkeletonCoverage::Complete,
nodes,
skins,
}
}
fn extract_assets(
doc: &gltf::Document,
buffers: &[Vec<u8>],
base: Option<&Path>,
bone_of_node: &[Option<usize>],
) -> SceneAssets {
let mut assets = SceneAssets::default();
let source_images = extract_source_images(doc, buffers, base);
let (raw_images, source_image_records): (Vec<_>, Vec<_>) = source_images
.into_iter()
.map(|image| (image.texture, image.record))
.unzip();
assets.material_resources = MaterialResourceAssets {
coverage: MaterialResourceCoverage::Complete,
materials: Vec::new(),
textures: extract_source_textures(doc),
images: source_image_records,
};
for material in doc.materials() {
let Some(material_index) = material.index() else {
continue;
};
let pbr = material.pbr_metallic_roughness();
assets
.material_resources
.materials
.push(SourceMaterialAsset {
material_index,
name: material.name().map(str::to_owned),
texture_bindings: source_material_texture_bindings(&material),
});
let base_color_texture = pbr.base_color_texture().and_then(|info| {
raw_images
.get(info.texture().source().index())
.and_then(|image| image.clone())
});
let normal_texture = material.normal_texture().and_then(|info| {
raw_images
.get(info.texture().source().index())
.and_then(|image| image.clone())
.map(|texture| NormalTextureAsset {
texture,
scale: info.scale(),
})
});
let metallic_roughness_texture = pbr.metallic_roughness_texture().and_then(|info| {
raw_images
.get(info.texture().source().index())
.and_then(|image| image.clone())
});
let occlusion_texture = material.occlusion_texture().and_then(|info| {
raw_images
.get(info.texture().source().index())
.and_then(|image| image.clone())
.map(|texture| OcclusionTextureAsset {
texture,
strength: info.strength(),
})
});
assets.materials.push(MaterialAsset {
name: material.name().unwrap_or("material").to_string(),
base_color: pbr.base_color_factor(),
metallic: pbr.metallic_factor(),
roughness: pbr.roughness_factor(),
base_color_texture,
normal_texture,
metallic_roughness_texture,
occlusion_texture,
});
}
let mut core_mesh_of_source = vec![None; doc.meshes().count()];
for mesh in doc.meshes() {
let mut primitives = Vec::new();
for prim in mesh.primitives() {
if prim.mode() != gltf::mesh::Mode::Triangles {
continue;
}
let reader = prim.reader(|b| buffers.get(b.index()).map(Vec::as_slice));
let has = |sem: gltf::Semantic| prim.get(&sem).is_some_and(|a| a.count() > 0);
if !has(gltf::Semantic::Positions) {
continue;
}
let positions: Vec<Vec3> = reader
.read_positions()
.map(|it| it.map(Vec3::from_array).collect())
.unwrap_or_default();
let normals = if has(gltf::Semantic::Normals) {
reader
.read_normals()
.map(|it| it.map(Vec3::from_array).collect())
.unwrap_or_default()
} else {
Vec::new()
};
let uvs = if has(gltf::Semantic::TexCoords(0)) {
reader
.read_tex_coords(0)
.map(|tc| tc.into_f32().collect())
.unwrap_or_default()
} else {
Vec::new()
};
let (joints, weights) =
if has(gltf::Semantic::Joints(0)) && has(gltf::Semantic::Weights(0)) {
match (reader.read_joints(0), reader.read_weights(0)) {
(Some(j), Some(w)) => (j.into_u16().collect(), w.into_f32().collect()),
_ => (Vec::new(), Vec::new()),
}
} else {
(Vec::new(), Vec::new())
};
let mut additional_influence_sets: BTreeMap<u32, AdditionalInfluenceSet> =
BTreeMap::new();
for (semantic, accessor) in prim.attributes() {
if accessor.count() == 0 {
continue;
}
match semantic {
gltf::Semantic::Joints(set) if set >= 1 => {
additional_influence_sets
.entry(set)
.and_modify(|entry| entry.joints_present = true)
.or_insert(AdditionalInfluenceSet {
set_index: set,
joints_present: true,
weights_present: false,
});
}
gltf::Semantic::Weights(set) if set >= 1 => {
additional_influence_sets
.entry(set)
.and_modify(|entry| entry.weights_present = true)
.or_insert(AdditionalInfluenceSet {
set_index: set,
joints_present: false,
weights_present: true,
});
}
_ => {}
}
}
let indices = if prim.indices().is_some_and(|a| a.count() > 0) {
reader
.read_indices()
.map(|it| it.into_u32().collect())
.unwrap_or_default()
} else {
Vec::new()
};
primitives.push(Primitive {
material: prim.material().index(),
indices,
positions,
normals,
uvs,
joints,
weights,
additional_influence_sets: additional_influence_sets.into_values().collect(),
});
}
if primitives.is_empty() {
continue;
}
let core_mesh = assets.meshes.len();
core_mesh_of_source[mesh.index()] = Some(core_mesh);
assets.meshes.push(MeshAsset {
name: mesh.name().unwrap_or("mesh").to_string(),
source_mesh_index: mesh.index(),
primitives,
});
}
for node in doc.nodes() {
let Some(source_mesh) = node.mesh() else {
continue;
};
let Some(mesh) = core_mesh_of_source[source_mesh.index()] else {
continue;
};
let skin = node.skin();
let skin_joints = skin
.as_ref()
.map(|skin| {
skin.joints()
.map(|joint| bone_of_node[joint.index()].unwrap_or(0))
.collect()
})
.unwrap_or_default();
let skin_ibms = skin
.as_ref()
.filter(|skin| {
skin.inverse_bind_matrices().is_some_and(|accessor| {
accessor.count() > 0 && inverse_bind_is_readable(&accessor)
})
})
.map(|skin| {
let reader = skin.reader(|buffer| buffers.get(buffer.index()).map(Vec::as_slice));
reader
.read_inverse_bind_matrices()
.map(|matrices| {
matrices
.map(|matrix| Mat4::from_cols_array_2d(&matrix))
.collect()
})
.unwrap_or_default()
})
.unwrap_or_default();
assets.instances.push(MeshInstance {
source_node_index: node.index(),
node: bone_of_node[node.index()].unwrap_or(0),
mesh,
skin_joints,
skin_ibms,
});
}
assets
}
fn extract_scenes(doc: &gltf::Document, bone_of_node: &[Option<usize>]) -> Vec<SceneAsset> {
doc.scenes()
.map(|scene| SceneAsset {
source_scene_index: scene.index(),
name: scene.name().map(str::to_owned),
roots: scene
.nodes()
.filter_map(|node| bone_of_node[node.index()])
.collect(),
})
.collect()
}
const MAX_IMAGE_ENCODED_BYTES: usize = 64 * 1024 * 1024;
const MAX_IMAGE_DECODE_ALLOC_BYTES: u64 = 192 * 1024 * 1024;
struct LoadedSourceImage {
record: SourceImageAsset,
texture: Option<TextureAsset>,
}
fn extract_source_images(
doc: &gltf::Document,
buffers: &[Vec<u8>],
base: Option<&Path>,
) -> Vec<LoadedSourceImage> {
let writer_images = writer_image_indices(doc);
doc.images()
.map(|image| {
let image_index = image.index();
let retain_raw = writer_images.contains(&image_index);
let name = image.name().map(str::to_owned);
let (source_kind, declared_mime_type, raw, unavailable_reason) = match image.source() {
gltf::image::Source::View { view, mime_type } => {
let bytes = buffers.get(view.buffer().index()).and_then(|buffer| {
view_end(&view).and_then(|end| buffer.get(view.offset()..end))
});
let (raw, reason) = match bytes {
Some(bytes) if !retain_raw && bytes.len() > MAX_IMAGE_ENCODED_BYTES => {
(None, ImageUnavailableReason::ResourceLimit)
}
Some(bytes) => (
Some(TextureAsset {
bytes: bytes.to_vec(),
mime: mime_type.to_string(),
}),
ImageUnavailableReason::SourceUnavailable,
),
None => (None, ImageUnavailableReason::SourceUnavailable),
};
(
ImageSourceKind::Embedded,
Some(mime_type.to_string()),
raw,
reason,
)
}
gltf::image::Source::Uri { uri, mime_type } => {
if let Some(encoded) = uri.strip_prefix("data:") {
let (mime_from_uri, raw, reason) =
read_data_uri_image(encoded, mime_type, retain_raw);
(
ImageSourceKind::DataUri,
mime_type.map(str::to_owned).or(mime_from_uri),
raw,
reason,
)
} else {
let path = base.and_then(|base| {
safe_external_buffer_path(uri)
.ok()
.map(|path| base.join(path))
});
let (raw, reason) = path
.map_or((None, ImageUnavailableReason::SourceUnavailable), |path| {
read_external_image(&path, mime_type, retain_raw)
});
(
ImageSourceKind::External,
mime_type.map(str::to_owned),
raw,
reason,
)
}
}
};
let (detected_container, inspection) =
inspect_source_image(raw.as_ref(), unavailable_reason);
LoadedSourceImage {
record: SourceImageAsset {
image_index,
name,
source_kind,
declared_mime_type,
detected_container,
inspection,
},
texture: if retain_raw { raw } else { None },
}
})
.collect()
}
fn writer_image_indices(doc: &gltf::Document) -> BTreeSet<usize> {
let mut images = BTreeSet::new();
for material in doc.materials() {
let pbr = material.pbr_metallic_roughness();
for texture in [
pbr.base_color_texture().map(|info| info.texture()),
material.normal_texture().map(|info| info.texture()),
pbr.metallic_roughness_texture().map(|info| info.texture()),
material.occlusion_texture().map(|info| info.texture()),
]
.into_iter()
.flatten()
{
images.insert(texture.source().index());
}
}
images
}
fn read_data_uri_image(
encoded: &str,
mime_type: Option<&str>,
retain_raw: bool,
) -> (Option<String>, Option<TextureAsset>, ImageUnavailableReason) {
let Some((metadata, payload)) = encoded.split_once(',') else {
return (None, None, ImageUnavailableReason::InvalidDataUri);
};
if !metadata.ends_with(";base64") {
return (None, None, ImageUnavailableReason::InvalidDataUri);
}
let mime_from_uri = metadata
.strip_suffix(";base64")
.filter(|mime| !mime.is_empty())
.map(str::to_owned);
if !retain_raw && estimated_base64_decoded_len(payload.len()) > MAX_IMAGE_ENCODED_BYTES {
return (mime_from_uri, None, ImageUnavailableReason::ResourceLimit);
}
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(payload) else {
return (mime_from_uri, None, ImageUnavailableReason::InvalidDataUri);
};
if !retain_raw && bytes.len() > MAX_IMAGE_ENCODED_BYTES {
return (mime_from_uri, None, ImageUnavailableReason::ResourceLimit);
}
let mime = mime_type
.map(str::to_owned)
.or_else(|| mime_from_uri.clone())
.unwrap_or_default();
(
mime_from_uri,
Some(TextureAsset { bytes, mime }),
ImageUnavailableReason::InvalidDataUri,
)
}
fn estimated_base64_decoded_len(encoded_len: usize) -> usize {
encoded_len.saturating_add(3) / 4 * 3
}
fn read_external_image(
path: &Path,
mime_type: Option<&str>,
retain_raw: bool,
) -> (Option<TextureAsset>, ImageUnavailableReason) {
let bytes = if retain_raw {
std::fs::read(path).ok()
} else {
let file = std::fs::File::open(path).ok();
file.and_then(|file| {
let mut bytes = Vec::new();
file.take((MAX_IMAGE_ENCODED_BYTES + 1) as u64)
.read_to_end(&mut bytes)
.ok()
.map(|_| bytes)
})
};
let Some(bytes) = bytes else {
return (None, ImageUnavailableReason::SourceUnavailable);
};
if !retain_raw && bytes.len() > MAX_IMAGE_ENCODED_BYTES {
return (None, ImageUnavailableReason::ResourceLimit);
}
(
Some(TextureAsset {
bytes,
mime: mime_type.unwrap_or_default().to_owned(),
}),
ImageUnavailableReason::SourceUnavailable,
)
}
fn source_material_texture_bindings(
material: &gltf::Material<'_>,
) -> Vec<SourceMaterialTextureBinding> {
let pbr = material.pbr_metallic_roughness();
let mut texture_bindings = Vec::with_capacity(5);
let mut push = |slot, texture: Option<gltf::Texture>| {
if let Some(texture) = texture {
texture_bindings.push(SourceMaterialTextureBinding {
slot,
texture_index: texture.index(),
});
}
};
push(
MaterialTextureSlot::BaseColor,
pbr.base_color_texture().map(|info| info.texture()),
);
push(
MaterialTextureSlot::Normal,
material.normal_texture().map(|info| info.texture()),
);
push(
MaterialTextureSlot::MetallicRoughness,
pbr.metallic_roughness_texture().map(|info| info.texture()),
);
push(
MaterialTextureSlot::Occlusion,
material.occlusion_texture().map(|info| info.texture()),
);
push(
MaterialTextureSlot::Emissive,
material.emissive_texture().map(|info| info.texture()),
);
texture_bindings
}
fn extract_source_textures(doc: &gltf::Document) -> Vec<SourceTextureAsset> {
doc.textures()
.map(|texture| SourceTextureAsset {
texture_index: texture.index(),
name: texture.name().map(str::to_owned),
image_index: texture.source().index(),
})
.collect()
}
fn inspect_source_image(
texture: Option<&TextureAsset>,
unavailable_reason: ImageUnavailableReason,
) -> (Option<ImageContainerFormat>, SourceImageInspection) {
let Some(texture) = texture else {
return (
None,
SourceImageInspection::Unavailable {
reason: unavailable_reason,
},
);
};
if texture.bytes.len() > MAX_IMAGE_ENCODED_BYTES {
return (
detect_container(&texture.bytes),
SourceImageInspection::Unavailable {
reason: ImageUnavailableReason::ResourceLimit,
},
);
}
let Some((format, detected_container)) = image_format(&texture.bytes) else {
return (
None,
SourceImageInspection::Unavailable {
reason: ImageUnavailableReason::UnsupportedContainer,
},
);
};
let mut reader = ImageReader::new(Cursor::new(&texture.bytes));
reader.set_format(format);
let mut limits = Limits::default();
limits.max_alloc = Some(MAX_IMAGE_DECODE_ALLOC_BYTES);
reader.limits(limits);
match reader.decode() {
Ok(decoded) => {
let color_type = match decoded.color() {
ColorType::L8 => Some(DecodedImageColorType::L8),
ColorType::La8 => Some(DecodedImageColorType::La8),
ColorType::Rgb8 => Some(DecodedImageColorType::Rgb8),
ColorType::Rgba8 => Some(DecodedImageColorType::Rgba8),
ColorType::L16 => Some(DecodedImageColorType::L16),
ColorType::La16 => Some(DecodedImageColorType::La16),
ColorType::Rgb16 => Some(DecodedImageColorType::Rgb16),
ColorType::Rgba16 => Some(DecodedImageColorType::Rgba16),
_ => None,
};
let (width, height) = (decoded.width(), decoded.height());
match color_type {
Some(color_type) => (
Some(detected_container),
SourceImageInspection::Available {
width,
height,
channel_count: decoded.color().channel_count(),
color_type,
},
),
None => (
Some(detected_container),
SourceImageInspection::Unavailable {
reason: ImageUnavailableReason::DecodeFailed,
},
),
}
}
Err(ImageError::Limits(_)) => (
Some(detected_container),
SourceImageInspection::Unavailable {
reason: ImageUnavailableReason::ResourceLimit,
},
),
Err(_) => (
Some(detected_container),
SourceImageInspection::Unavailable {
reason: ImageUnavailableReason::DecodeFailed,
},
),
}
}
fn image_format(bytes: &[u8]) -> Option<(ImageFormat, ImageContainerFormat)> {
if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
Some((ImageFormat::Png, ImageContainerFormat::Png))
} else if bytes.starts_with(&[0xff, 0xd8, 0xff]) {
Some((ImageFormat::Jpeg, ImageContainerFormat::Jpeg))
} else {
None
}
}
fn detect_container(bytes: &[u8]) -> Option<ImageContainerFormat> {
image_format(bytes).map(|(_, container)| container)
}