Files
craft-libs/crates/aep/src/settings.rs
T
Dylan M. Taylorandvangel76 4a7024cd69 craft-aep: flat read-only tree index and the Pard decoder (ports of effectcraft#3302 and #3319) (#948)
* craft-aep: a flat read-only tree index, borrowed text documents, fewer allocations

The container part of storytold/effectcraft#3302:

- `TreeIndex`: the chunk tree as one flat array in depth-first order,
  borrowed from the input, for readers that only look. Same chunks counted
  against the same limits and the same errors as `parse`. An `.aepx` is
  indexed by `index_aepx` into one buffer. The typed view reads through
  either tree (`Node`, `Root`, `Project::from_index`).
- Chunk parser: each list of siblings is counted, then reserved once.
  `.aepx`: hex decoded into an output sized once, attribute values without
  an entity borrowed. `write` reserves the exact output size.
- Text documents are parsed into a tree that borrows from the body; the
  style and paragraph defaults are built once.
- Layer markers and Source Text are read where they are stored instead of
  searched for.
- Container accessors that only tests used are removed.

What the importer and exporter in EffectCraft reach across the crate
boundary is public here: `Root`, `Draft`, `AepxDrafts`, `index_aepx`,
`TreeIndex::from_drafts`, `Project::of`, `ProjectSettings::of`,
`Node::marker_list`, `Root::file_version` and `saved_by_version`,
`StyleRun::SAMPLED`. `TimeBase::NTSC` holds the rates as plain
numerator and denominator instead of an EffectCraft type.

* craft-aep: one decoder for an effect parameter definition (`Pard`)

The container part of storytold/effectcraft#3319: the fields of a `pard`
record (type code, name, default, valid range, slider range, popup item
count) are read by one type, `Pard`, which `EffectParam` now reads
through. It is public so that an exporter's tests can read back what
they encode instead of keeping a second decoder.

The rest of that PR (import and export bounds, loss reports) is in
EffectCraft's importer and exporter.

---------

Co-authored-by: vangel76 <vangel1976@googlemail.com>
2026-11-11 19:01:12 -05:00

238 lines
9.7 KiB
Rust

//! Project settings (File ▸ Project Settings): colour depth, working space, working gamma, the two
//! linear-light switches, the time display and the frame count start. Cases 620-634: one change
//! each against the base project 620; every field below is the only byte (or chunk) that changes.
use crate::bytes::be_u32;
use crate::chunk::AepFile;
use crate::footage::json_string;
use crate::index::{Node, Root, TreeIndex};
/// The time display style (`nhed` byte 8, feet + frames byte 11, film type byte 13).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TimeDisplay {
/// Timecode (the base, 620): byte 8 = 0.
Timecode,
/// Frames (629): byte 8 = 1.
Frames,
/// Feet + frames (633, 634): byte 8 = 1 and byte 11 = 1; byte 13 is the frames per foot,
/// 16 for 35 mm (633) and 40 for 16 mm (634).
FeetFrames { frames_per_foot: u8 },
}
/// The working colour space (`cpid` and the JSON `Utf8` after `PwCs`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WorkingSpace {
/// No colour management (623): `cpid` is 16 bytes of `0xff` and the JSON is `{}`.
None,
/// A colour profile, named as After Effects lists it (`baseColorProfile/colorProfileName`):
/// `sRGB IEC61966-2.1` in the base (620), `Rec.709 Gamma 2.4` in 624. The profile data next to
/// the name is kept as stored.
Profile(String),
}
/// The project settings that are decoded.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProjectSettings {
/// Bits per channel: 8, 16 or 32 (`nhed` byte 15 = 0 / 1 / 2: 621, 622, 620; the same byte
/// is `nnhd` byte 24). `None` for another value.
pub bits_per_channel: Option<u8>,
/// The working space; `None` when the chunks are missing or unreadable.
pub working_space: Option<WorkingSpace>,
/// Working gamma 2.4 (`dwga` `u32` @0 = 1, 625) or 2.2 (0, 620).
pub working_gamma_24: Option<bool>,
/// Linearize Working Space: a 1-byte `lnrp` chunk holding 1 exists (626, 632).
pub linearize: bool,
/// Blend Colors Using 1.0 Gamma: a 1-byte `lnrb` chunk holding 1 exists (627, 632).
pub blend_linear: bool,
/// Compensate for Scene-referred Profiles (`acer` = 1 in the base, 0 in 628).
pub scene_compensation: Option<bool>,
/// The time display style.
pub time_display: Option<TimeDisplay>,
/// Frame count start: `nhed` byte 14 holds 2 for a count from 0 (620) and 1 for a count from
/// 1 (630); this maps 2 to 0 and 1 to 1, anything else to `None`.
pub frame_start: Option<u8>,
}
impl ProjectSettings {
/// Reads the settings from the top-level chunks.
pub fn read(file: &AepFile<'_>) -> ProjectSettings {
Self::of(Root::from(file))
}
/// Reads the settings from the top-level chunks of an indexed file.
pub fn read_index(index: &TreeIndex<'_>) -> ProjectSettings {
Self::of(Root::from(index))
}
/// Reads the settings from the top-level chunks of either tree.
pub fn of(file: Root<'_, '_>) -> ProjectSettings {
let nhed = file.chunk(b"nhed").and_then(Node::bytes);
let byte = |i: usize| nhed.and_then(|b| b.get(i).copied());
let bits_per_channel = byte(15).and_then(|v| match v {
0 => Some(8),
1 => Some(16),
2 => Some(32),
_ => None,
});
let time_display = match (byte(8), byte(11), byte(13)) {
(Some(0), _, _) => Some(TimeDisplay::Timecode),
(Some(1), Some(0), _) => Some(TimeDisplay::Frames),
(Some(1), Some(1), Some(fpf)) => Some(TimeDisplay::FeetFrames { frames_per_foot: fpf }),
_ => None,
};
let frame_start = byte(14).and_then(|v| match v {
2 => Some(0),
1 => Some(1),
_ => None,
});
let flag = |id: &[u8; 4]| file.chunk(id).and_then(Node::bytes).and_then(|b| b.first().copied()) == Some(1);
let working_gamma_24 = file.chunk(b"dwga").and_then(Node::bytes).and_then(|b| b.get(..4)).map(|b| b != [0, 0, 0, 0]);
let scene_compensation = file.chunk(b"acer").and_then(Node::bytes).and_then(|b| b.first().copied()).map(|v| v != 0);
ProjectSettings {
bits_per_channel,
working_space: working_space(file),
working_gamma_24,
linearize: flag(b"lnrp"),
blend_linear: flag(b"lnrb"),
scene_compensation,
time_display,
frame_start,
}
}
}
/// The working space: the `Utf8` right after `PwCs`.
fn working_space(file: Root<'_, '_>) -> Option<WorkingSpace> {
let mut chunks = file.chunks();
chunks.find(|c| c.is(b"PwCs"))?;
let json = chunks.next().filter(|c| c.is(b"Utf8"))?.bytes()?;
let trimmed: Vec<u8> = json.iter().copied().filter(|b| !b.is_ascii_whitespace() && *b != 0).collect();
if trimmed == b"{}" {
return Some(WorkingSpace::None);
}
// After Effects 26.2.1 saves the profile data without the name (settings-c, 902): the name is
// then the profile's own description (ICC.1 `desc`).
json_string(json, "colorProfileName")
.or_else(|| json_string(json, "colorProfileData").and_then(|b| base64_decode(&b)).and_then(|icc| icc_description(&icc)))
.map(WorkingSpace::Profile)
}
/// Standard base64 (RFC 4648) with or without padding; `None` for any other character.
fn base64_decode(text: &str) -> Option<Vec<u8>> {
let mut out = Vec::with_capacity(text.len() / 4 * 3);
let (mut acc, mut bits) = (0u32, 0u32);
for c in text.bytes().filter(|c| *c != b'=') {
let v = 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'+' => 62,
b'/' => 63,
_ => return None,
};
acc = (acc << 6 | u32::from(v)) & 0x00ff_ffff;
bits += 6;
if bits >= 8 {
bits -= 8;
out.push(((acc >> bits) & 0xff) as u8);
}
}
Some(out)
}
/// The profile description (ICC.1 tag `desc`): a v2 `desc` (ASCII) or a v4 `mluc` (its first
/// record, UTF-16BE). `None` when the profile or the tag is cut short.
fn icc_description(icc: &[u8]) -> Option<String> {
let be32 = |at: usize| be_u32(icc, at);
let count = be32(128)?;
let tag = (0..count.min(1024)).find_map(|i| {
let at = 132usize.checked_add(usize::try_from(i).ok()?.checked_mul(12)?)?;
(icc.get(at..at.checked_add(4)?)? == b"desc").then_some(at)
})?;
let off = usize::try_from(be32(tag.checked_add(4)?)?).ok()?;
let len = usize::try_from(be32(tag.checked_add(8)?)?).ok()?;
let body = icc.get(off..off.checked_add(len)?)?;
let text = match body.get(..4)? {
b"desc" => {
let n = usize::try_from(be_u32(body, 8)?).ok()?;
let ascii = body.get(12..12usize.checked_add(n)?)?;
String::from_utf8_lossy(ascii.split(|b| *b == 0).next().unwrap_or_default()).into_owned()
}
b"mluc" => {
let n = usize::try_from(be_u32(body, 20)?).ok()?;
let at = usize::try_from(be_u32(body, 24)?).ok()?;
let units: Vec<u16> = body.get(at..at.checked_add(n)?)?.as_chunks::<2>().0.iter().map(|p| u16::from_be_bytes(*p)).collect();
String::from_utf16_lossy(&units)
}
_ => return None,
};
let text = text.trim_end_matches('\0').trim().to_owned();
(!text.is_empty()).then_some(text)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn base64_decodes_with_and_without_padding() {
assert_eq!(base64_decode("Zm9vYmFy").as_deref(), Some(&b"foobar"[..]));
assert_eq!(base64_decode("Zm9vYmE=").as_deref(), Some(&b"fooba"[..]));
assert_eq!(base64_decode("Zm9vYg").as_deref(), Some(&b"foob"[..]));
assert_eq!(base64_decode("Zm9v*"), None);
}
/// A profile with one tag, `desc`, holding `body`.
fn profile(body: &[u8]) -> Vec<u8> {
let mut icc = vec![0u8; 128];
icc.extend_from_slice(&1u32.to_be_bytes());
icc.extend_from_slice(b"desc");
icc.extend_from_slice(&144u32.to_be_bytes());
icc.extend_from_slice(&u32::try_from(body.len()).unwrap().to_be_bytes());
icc.extend_from_slice(body);
icc
}
/// A version 2 `desc` body.
fn desc(text: &str) -> Vec<u8> {
let mut b = b"desc\0\0\0\0".to_vec();
b.extend_from_slice(&u32::try_from(text.len() + 1).unwrap().to_be_bytes());
b.extend_from_slice(text.as_bytes());
b.push(0);
b
}
/// A version 4 `mluc` body with one record.
fn mluc(text: &str) -> Vec<u8> {
let units: Vec<u8> = text.encode_utf16().flat_map(u16::to_be_bytes).collect();
let mut b = b"mluc\0\0\0\0".to_vec();
b.extend_from_slice(&1u32.to_be_bytes());
b.extend_from_slice(&12u32.to_be_bytes());
b.extend_from_slice(b"enUS");
b.extend_from_slice(&u32::try_from(units.len()).unwrap().to_be_bytes());
b.extend_from_slice(&28u32.to_be_bytes());
b.extend_from_slice(&units);
b
}
#[test]
fn the_description_of_a_profile_is_read() {
assert_eq!(icc_description(&profile(&desc("Rec.709 Gamma 2.4"))).as_deref(), Some("Rec.709 Gamma 2.4"));
assert_eq!(icc_description(&profile(&mluc("sRGB IEC61966-2.1"))).as_deref(), Some("sRGB IEC61966-2.1"));
assert_eq!(icc_description(&profile(&desc(""))), None);
}
#[test]
fn a_cut_or_damaged_profile_gives_no_description() {
let icc = profile(&mluc("Rec.709 Gamma 2.4"));
for n in 0..icc.len() {
let mut cut = icc.get(..n).unwrap_or_default().to_vec();
let _ = icc_description(&cut);
cut.iter_mut().for_each(|b| *b = 0xff);
let _ = icc_description(&cut);
}
assert_eq!(icc_description(&icc[..150]), None);
assert_eq!(icc_description(&[]), None);
}
}