mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-29 12:37:08 -06:00
New crate effectcraft-lottie (L3): export a comp to Lottie JSON / dotLottie (precomp and base64 image assets; shape, solid, null, precomp, image and text layers or glyph shapes; transforms with parenting, separated position, anchor; keyframes with speed/influence converted to normalised i/o tangents, hold keys, spatial ti/to; all shape items incl. gradients, dashes, trim, repeater and path operators; masks, track mattes, blend modes, time remap, timing, markers, optional expressions, a few Lottie effects) with a warnings list for what Lottie cannot express, and the inverse import. Commands file.exportLottie / file.importLottie with File > Export / Import entries. Round-trip tests (values and rendered frames) and hand-written fixtures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
70 lines
2.3 KiB
Rust
70 lines
2.3 KiB
Rust
//! Standard base64 (RFC 4648 §4) for embedded image assets.
|
|
|
|
const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
|
|
pub fn encode(data: &[u8]) -> String {
|
|
let mut out = String::with_capacity(data.len().div_ceil(3) * 4);
|
|
for chunk in data.chunks(3) {
|
|
let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
|
|
let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32;
|
|
for i in 0..4 {
|
|
if i <= chunk.len() {
|
|
out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char);
|
|
} else {
|
|
out.push('=');
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn decode(s: &str) -> Option<Vec<u8>> {
|
|
let val = |c: u8| -> Option<u32> {
|
|
Some(match c {
|
|
b'A'..=b'Z' => c - b'A',
|
|
b'a'..=b'z' => c - b'a' + 26,
|
|
b'0'..=b'9' => c - b'0' + 52,
|
|
b'+' | b'-' => 62,
|
|
b'/' | b'_' => 63,
|
|
_ => return None,
|
|
} as u32)
|
|
};
|
|
let clean: Vec<u8> = s.bytes().filter(|c| !c.is_ascii_whitespace() && *c != b'=').collect();
|
|
let mut out = Vec::with_capacity(clean.len() * 3 / 4);
|
|
for chunk in clean.chunks(4) {
|
|
let mut n = 0u32;
|
|
for (i, &c) in chunk.iter().enumerate() {
|
|
n |= val(c)? << (18 - 6 * i);
|
|
}
|
|
let bytes = [(n >> 16) as u8, (n >> 8) as u8, n as u8];
|
|
match chunk.len() {
|
|
4 => out.extend_from_slice(&bytes),
|
|
3 => out.extend_from_slice(&bytes[..2]),
|
|
2 => out.push(bytes[0]),
|
|
_ => return None,
|
|
}
|
|
}
|
|
Some(out)
|
|
}
|
|
|
|
/// Split a `data:<mime>;base64,<payload>` URI into (mime, bytes).
|
|
pub fn data_uri(s: &str) -> Option<(String, Vec<u8>)> {
|
|
let rest = s.strip_prefix("data:")?;
|
|
let (head, payload) = rest.split_once(',')?;
|
|
let mime = head.strip_suffix(";base64")?;
|
|
Some((mime.to_string(), decode(payload)?))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#[test]
|
|
fn roundtrip() {
|
|
for s in ["", "f", "fo", "foo", "foob", "fooba", "foobar"] {
|
|
let e = super::encode(s.as_bytes());
|
|
assert_eq!(super::decode(&e).unwrap(), s.as_bytes());
|
|
}
|
|
assert_eq!(super::encode(b"foobar"), "Zm9vYmFy");
|
|
assert_eq!(super::encode(b"fo"), "Zm8=");
|
|
}
|
|
}
|