Skip to main content

fbx_dom/objects/
video.rs

1//! FBX `Video` — Assimp [`Video`](https://github.com/assimp/assimp/blob/master/code/AssetLib/FBX/FBXMaterial.cpp).
2//!
3//! ASCII `Content` is optional base64 (often several value tokens); we decode each token and
4//! concatenate the bytes. Assimp’s binary `Content` (`R` + length + raw bytes) is not handled here.
5
6use std::collections::HashMap;
7use std::convert::TryFrom;
8
9use fbxscii::ElementAttribute;
10
11use crate::{OwnedObject, objects::AttrExtractorExt};
12
13use super::{FbxObjectTag, FbxTryFromReason, FbxTypeMismatch, fbx_object_tag};
14
15const TYPE_ATTR: &str = "Type";
16const FILE_NAME_ATTR: &str = "FileName";
17const RELATIVE_FILENAME_ATTR: &str = "RelativeFilename";
18const CONTENT_ATTR: &str = "Content";
19
20#[derive(Debug, PartialEq)]
21pub struct Video {
22    object: OwnedObject,
23    /// `Type` child (case-insensitive key); e.g. clip vs embedded video metadata from the exporter.
24    pub video_type: String,
25    /// `FileName` / `Filename` (case-insensitive), per Assimp `FindElementCaseInsensitive` for video.
26    pub file_name: String,
27    /// `RelativeFilename` when present (case-insensitive key).
28    pub relative_file_name: Option<String>,
29    /// Decoded `Content` when present: each DOM token is its own base64 payload; outputs are concatenated.
30    pub content: Option<Vec<u8>>,
31}
32
33impl Video {
34    pub fn inner(&self) -> &OwnedObject {
35        &self.object
36    }
37
38    pub fn into_inner(self) -> OwnedObject {
39        self.object
40    }
41}
42
43/// Parse optional `Content`: missing attribute, missing tokens, or all-empty tokens → `None`.
44///
45/// FBX may split large embedded payloads across **multiple** value tokens. Each token is a
46/// separate base64 string (Assimp decodes per token then appends); you cannot decode the
47/// concatenation of raw token text as one base64 stream.
48fn decode_optional_content(
49    attrs: &HashMap<String, ElementAttribute>,
50) -> Result<Option<Vec<u8>>, FbxTryFromReason> {
51    let Some(tokens) = attrs.optional_tokens_case_insensitive(CONTENT_ATTR)? else {
52        return Ok(None);
53    };
54    if tokens.is_empty() {
55        return Ok(None);
56    }
57
58    // One decode per token, then concat — same semantics as Assimp’s two-pass size+decode loop.
59    let mut out = Vec::new();
60    for (i, t) in tokens.iter().enumerate() {
61        let s = t.trim();
62        // ASCII FBX often wraps each chunk in double quotes (Assimp strips them).
63        let payload = if s.len() >= 2 && s.starts_with('"') && s.ends_with('"') {
64            &s[1..s.len() - 1]
65        } else {
66            s
67        };
68        let decoded =
69            base64::decode(payload).map_err(|e| FbxTryFromReason::InvalidAttributeFormat {
70                name: CONTENT_ATTR.to_string(),
71                detail: format!("base64 decode (token {i}): {e}"),
72            })?;
73        if decoded.is_empty() {
74            return Err(FbxTryFromReason::InvalidAttributeFormat {
75                name: CONTENT_ATTR.to_string(),
76                detail: format!("base64 token {i} decoded to empty"),
77            });
78        }
79        out.extend_from_slice(&decoded);
80    }
81
82    Ok(Some(out))
83}
84
85impl TryFrom<OwnedObject> for Video {
86    type Error = FbxTypeMismatch;
87
88    fn try_from(o: OwnedObject) -> Result<Self, Self::Error> {
89        if fbx_object_tag(&o) != FbxObjectTag::Video {
90            return Err(FbxTypeMismatch::wrong_object_kind(o, "Video".to_string()));
91        }
92
93        let attrs = &o.attributes;
94        // Case-insensitive keys match common exporter spelling drift (e.g. `Filename` vs `FileName`).
95        let video_type = match attrs.require_token_case_insensitive(TYPE_ATTR) {
96            Ok(s) => s.to_string(),
97            Err(reason) => return Err(FbxTypeMismatch { object: o, reason }),
98        };
99
100        let file_name = match attrs.require_token_case_insensitive(FILE_NAME_ATTR) {
101            Ok(s) => s.to_string(),
102            Err(reason) => return Err(FbxTypeMismatch { object: o, reason }),
103        };
104        let relative_file_name = match attrs.optional_token_case_insensitive(RELATIVE_FILENAME_ATTR)
105        {
106            Ok(r) => r.map(|s| s.to_string()),
107            Err(reason) => return Err(FbxTypeMismatch { object: o, reason }),
108        };
109        let content = match decode_optional_content(attrs) {
110            Ok(c) => c,
111            Err(reason) => return Err(FbxTypeMismatch { object: o, reason }),
112        };
113
114        Ok(Video {
115            object: o,
116            video_type,
117            file_name,
118            relative_file_name,
119            content,
120        })
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use fbxscii::{ElementAttribute, LeafAttribute};
127
128    use super::*;
129
130    #[test]
131    fn decode_content_quoted_base64() {
132        let mut attrs = HashMap::new();
133        attrs.insert(
134            "Content".to_string(),
135            ElementAttribute::Leaf(Box::new(LeafAttribute {
136                key: "Content".into(),
137                tokens: vec!["\"aGVsbG8=\"".into()],
138            })),
139        );
140        let out = decode_optional_content(&attrs).unwrap().unwrap();
141        assert_eq!(out, b"hello");
142    }
143
144    #[test]
145    fn decode_content_multipart_concat() {
146        let mut attrs = HashMap::new();
147        attrs.insert(
148            "content".to_string(),
149            ElementAttribute::Leaf(Box::new(LeafAttribute {
150                key: "content".into(),
151                tokens: vec!["aGVs".into(), "bG8=".into()],
152            })),
153        );
154        let out = decode_optional_content(&attrs).unwrap().unwrap();
155        assert_eq!(out, b"hello");
156    }
157
158    #[test]
159    fn decode_content_missing() {
160        let attrs = HashMap::new();
161        assert!(decode_optional_content(&attrs).unwrap().is_none());
162    }
163}