filmcraft/crates/interchange/src/aaf/read.rs

576 lines
25 KiB
Rust

//! AAF objects → composition model.
use std::collections::{HashMap, HashSet};
use filmcraft_time::{FrameRate, Tick};
use super::ids::{cls, ddef, def, pid};
use super::store::{self, Auid, Obj, Value, rational_of, utf16_of};
use crate::comp::{CClip, CItem, CKind, CMarker, CTrack, CTransition, Composition, Document, Gain, Source, units_to_ticks};
use crate::{Error, Report, Result};
type MobId = [u8; 32];
#[derive(Clone, Copy, PartialEq, Eq)]
enum DataKind {
Picture,
Sound,
Timecode,
Other,
}
fn data_kind(o: &Obj) -> DataKind {
match o.weak_key(pid::DATA_DEFINITION).and_then(|k| <Auid>::try_from(k).ok()) {
Some(k) if k == ddef::PICTURE || k == ddef::LEGACY_PICTURE => DataKind::Picture,
Some(k) if k == ddef::SOUND || k == ddef::LEGACY_SOUND => DataKind::Sound,
Some(k) if k == ddef::TIMECODE || k == ddef::LEGACY_TIMECODE => DataKind::Timecode,
Some(_) => DataKind::Other,
None => DataKind::Other,
}
}
fn len_of(o: &Obj) -> i64 {
o.i64(pid::LENGTH).unwrap_or(0).max(0)
}
/// The value of an indirect property: (type id, bytes).
fn indirect(d: &[u8]) -> Option<(Auid, &[u8])> {
if d.len() >= 17 && (d[0] == 0x4C || d[0] == 0x42) {
return Some((d[1..17].try_into().ok()?, &d[17..]));
}
if d.len() >= 16 {
return Some((d[..16].try_into().ok()?, &d[16..]));
}
None
}
fn tagged_values(o: &Obj, p: u16) -> Vec<(String, String)> {
o.objs(p)
.iter()
.filter_map(|t| {
let name = t.string(pid::TAG_NAME)?;
let (ty, v) = indirect(t.data(pid::TAG_VALUE)?)?;
let value = if ty == def::TYPE_STRING || v.len() % 2 == 0 { utf16_of(v) } else { String::new() };
Some((name, value))
})
.collect()
}
fn indirect_f64(d: &[u8]) -> Option<f64> {
let (_, v) = indirect(d)?;
match v.len() {
8 => rational_of(v).filter(|r| r.1 != 0).map(|(n, d)| n as f64 / d as f64),
4 => Some(i32::from_le_bytes(v.try_into().ok()?) as f64),
_ => None,
}
}
struct R<'a> {
report: &'a mut Report,
mobs: HashMap<MobId, &'a Obj>,
essence: HashMap<MobId, &'a [u8]>,
doc: Document,
/// (file mob id, slot id, master key) → source index
sources: HashMap<(MobId, u32, String), usize>,
}
pub(crate) fn read(bytes: &[u8], report: &mut Report) -> Result<Document> {
let root = store::read(bytes).map_err(|e| Error::Parse { format: "AAF", message: e })?;
let header = root.strong(pid::ROOT_HEADER).ok_or_else(|| Error::Parse { format: "AAF", message: "no header object".into() })?;
let content = header.strong(pid::CONTENT).ok_or_else(|| Error::Parse { format: "AAF", message: "no content storage".into() })?;
let mut mobs = HashMap::new();
for m in content.objs(pid::MOBS) {
if let Some(id) = m.mob_id(pid::MOB_ID) {
mobs.insert(id, m);
}
}
let mut essence = HashMap::new();
for e in content.objs(pid::ESSENCE_DATA) {
if let (Some(id), Some(d)) = (e.mob_id(pid::ESSENCE_MOB_ID), e.stream(pid::ESSENCE_STREAM)) {
essence.insert(id, d);
}
}
let mut r = R { report, mobs, essence, doc: Document::default(), sources: HashMap::new() };
// top-level compositions: not referenced by another composition (usage code wins when present)
let comps: Vec<&Obj> = content.objs(pid::MOBS).iter().filter(|m| m.class == cls::COMPOSITION_MOB).collect();
let mut referenced = HashSet::new();
for c in &comps {
collect_refs(c, &mut referenced);
}
let tagged_top: Vec<&Obj> = comps.iter().copied().filter(|c| c.auid(pid::USAGE_CODE) == Some(def::USAGE_TOP_LEVEL)).collect();
let top: Vec<&Obj> = if !tagged_top.is_empty() {
tagged_top
} else {
comps.iter().copied().filter(|c| c.mob_id(pid::MOB_ID).is_none_or(|id| !referenced.contains(&id))).collect()
};
for c in top {
let comp = r.composition(c);
r.doc.compositions.push(comp);
}
if r.doc.compositions.is_empty() {
return Err(Error::Empty);
}
Ok(r.doc)
}
fn collect_refs(o: &Obj, out: &mut HashSet<MobId>) {
if o.class == cls::SOURCE_CLIP
&& let Some(id) = o.mob_id(pid::SOURCE_ID)
{
out.insert(id);
}
for (_, v) in &o.props {
match v {
Value::Strong(c) => collect_refs(c, out),
Value::StrongVec(v) | Value::StrongSet(v, _) => v.iter().for_each(|c| collect_refs(c, out)),
_ => {}
}
}
}
fn rate_of(o: &Obj, p: u16) -> Option<(i64, i64)> {
o.rational(p).filter(|r| r.0 > 0 && r.1 > 0)
}
impl R<'_> {
fn composition(&mut self, c: &Obj) -> Composition {
let name = c.string(pid::MOB_NAME).unwrap_or_else(|| "Sequence".into());
let slots = c.objs(pid::SLOTS);
let pic_rate = slots
.iter()
.filter(|s| s.class == cls::TIMELINE_MOB_SLOT)
.find(|s| s.strong(pid::SEGMENT).is_some_and(|g| matches!(data_kind(g), DataKind::Picture | DataKind::Timecode)))
.and_then(|s| rate_of(s, pid::EDIT_RATE))
.or_else(|| slots.iter().find_map(|s| rate_of(s, pid::EDIT_RATE)))
.unwrap_or((25, 1));
let rate = FrameRate::new(pic_rate.0, pic_rate.1);
let tags = tagged_values(c, pid::MOB_USER_COMMENTS);
let tag = |n: &str| tags.iter().find(|(k, _)| k == n).map(|(_, v)| v.clone());
let (mut width, mut height) = tag("FilmCraft Frame Size")
.and_then(|s| s.split_once('x').and_then(|(a, b)| Some((a.trim().parse().ok()?, b.trim().parse().ok()?))))
.unwrap_or((0, 0));
let mut sample_rate = tag("FilmCraft Audio Sample Rate").and_then(|s| s.parse().ok()).unwrap_or(0u32);
let mut comp =
Composition { name, rate, sample_rate: 48_000, width: 1920, height: 1080, start_tc: 0, drop: false, tracks: Vec::new(), markers: Vec::new() };
let (mut nv, mut na) = (0, 0);
for s in slots {
let Some(seg) = s.strong(pid::SEGMENT) else { continue };
if s.class == cls::EVENT_MOB_SLOT {
let er = rate_of(s, pid::EVENT_EDIT_RATE).unwrap_or(pic_rate);
comp.markers.extend(markers_of(seg, er));
continue;
}
let er = rate_of(s, pid::EDIT_RATE).unwrap_or(pic_rate);
let origin = s.i64(pid::ORIGIN).unwrap_or(0);
match data_kind(seg) {
DataKind::Timecode => {
if let Some(tc) = find_class(seg, cls::TIMECODE) {
comp.start_tc = tc.i64(pid::TC_START).unwrap_or(0);
comp.drop = tc.u8(pid::TC_DROP).unwrap_or(0) != 0;
}
}
kind @ (DataKind::Picture | DataKind::Sound) => {
let ckind = if kind == DataKind::Picture { CKind::Picture } else { CKind::Sound };
if ckind == CKind::Sound && sample_rate == 0 && er.1 == 1 && er.0 >= 8000 {
sample_rate = er.0 as u32;
}
let mut items = self.segment(seg, er, ckind, 0);
if origin > 0 {
// material before the origin is not shown
trim_front(&mut items, units_to_ticks(origin, er.0, er.1));
}
let number = s.u32(pid::PHYSICAL_TRACK_NUMBER).unwrap_or(0);
let name = s.string(pid::SLOT_NAME).unwrap_or_default();
let channels = if ckind == CKind::Sound {
items
.iter()
.filter_map(|i| match i {
CItem::Clip(c) => Some(self.doc.sources[c.source].channels),
_ => None,
})
.max()
.unwrap_or(1)
.min(6)
} else {
0
};
let n = if ckind == CKind::Picture {
nv += 1;
nv
} else {
na += 1;
na
};
comp.tracks.push(CTrack { kind: ckind, name, number: if number > 0 { number } else { n }, channels, items });
}
DataKind::Other => self.report.info("a slot of an unsupported data kind was skipped"),
}
}
if width == 0 {
let pic = self.doc.sources.iter().find(|s| s.kind == CKind::Picture && s.width > 0);
(width, height) = pic.map_or((1920, 1080), |p| (p.width, p.height));
}
comp.width = width;
comp.height = height;
if sample_rate > 0 {
comp.sample_rate = sample_rate;
}
comp
}
/// Items of a segment (a sequence or a single component).
fn segment(&mut self, seg: &Obj, rate: (i64, i64), kind: CKind, depth: usize) -> Vec<CItem> {
if depth > 32 {
self.report.warn("segments nested too deeply were skipped");
return vec![CItem::Filler(units_to_ticks(len_of(seg), rate.0, rate.1))];
}
if seg.class == cls::SEQUENCE {
let mut out = Vec::new();
for c in seg.objs(pid::COMPONENTS) {
out.extend(self.component(c, rate, kind, depth + 1));
}
return out;
}
self.component(seg, rate, kind, depth + 1)
}
fn component(&mut self, c: &Obj, rate: (i64, i64), kind: CKind, depth: usize) -> Vec<CItem> {
let len = units_to_ticks(len_of(c), rate.0, rate.1);
match c.class {
x if x == cls::FILLER => vec![CItem::Filler(len)],
x if x == cls::SOURCE_CLIP => vec![self.clip(c, rate, kind, None)],
x if x == cls::SEQUENCE => self.segment(c, rate, kind, depth),
x if x == cls::TRANSITION => {
let cut = units_to_ticks(c.i64(pid::CUT_POINT).unwrap_or(0), rate.0, rate.1);
let og = c.strong(pid::OPERATION_GROUP);
let effect = og
.and_then(|o| tagged_values(o, pid::COMPONENT_USER_COMMENTS).into_iter().find(|(k, _)| k == "FilmCraft Effect").map(|(_, v)| v))
.unwrap_or_else(|| {
let op = og.and_then(|o| o.weak_key(pid::OPERATION)).and_then(|k| <Auid>::try_from(k).ok());
match (kind, op) {
(CKind::Sound, _) => "constant_power".into(),
(_, Some(o)) if o == def::VIDEO_FADE_TO_BLACK => "dip_to_black".into(),
(_, Some(o)) if o == def::SMPTE_VIDEO_WIPE => "wipe".into(),
_ => "cross_dissolve".into(),
}
});
vec![CItem::Transition(CTransition { len, cut: cut.clamp(Tick::ZERO, len), effect })]
}
x if x == cls::OPERATION_GROUP => {
let op = c.weak_key(pid::OPERATION).and_then(|k| <Auid>::try_from(k).ok());
let inputs = c.objs(pid::INPUT_SEGMENTS);
let Some(input) = inputs.first() else {
self.report.info("an effect without input was imported as a gap");
return vec![CItem::Filler(len)];
};
let gain = if op == Some(def::MONO_AUDIO_GAIN) {
self.gain(c, len, rate)
} else {
self.report.warn("effects other than audio gain are not imported (their first input is used)");
None
};
let mut items = self.segment(input, rate, kind, depth);
// the operation's length rules
fit(&mut items, len);
if let Some(g) = gain {
match items.as_mut_slice() {
[CItem::Clip(cl)] => cl.gain = Some(g),
_ => self.report.warn("audio gain around several clips was not imported"),
}
}
items
}
x if x == cls::SELECTOR => match c.strong(pid::SELECTED) {
Some(s) => {
let mut items = self.segment(s, rate, kind, depth);
fit(&mut items, len);
items
}
None => vec![CItem::Filler(len)],
},
x if x == cls::NESTED_SCOPE => match c.objs(pid::NESTED_SLOTS).last() {
Some(s) => {
let mut items = self.segment(s, rate, kind, depth);
fit(&mut items, len);
items
}
None => vec![CItem::Filler(len)],
},
_ => {
self.report.info("an unsupported component was imported as a gap");
vec![CItem::Filler(len)]
}
}
}
fn gain(&mut self, og: &Obj, len: Tick, _rate: (i64, i64)) -> Option<Gain> {
let p = og
.objs(pid::PARAMETERS)
.iter()
.find(|p| p.auid(pid::PARAMETER_DEFINITION) == Some(def::PARAM_AMPLITUDE))
.or_else(|| og.objs(pid::PARAMETERS).first())?;
if p.class == cls::CONSTANT_VALUE {
return p.data(pid::CONSTANT_VALUE).and_then(indirect_f64).map(Gain::Constant);
}
if p.class == cls::VARYING_VALUE {
let linear = p.weak_key(pid::INTERPOLATION).and_then(|k| <Auid>::try_from(k).ok()) != Some(def::INTERP_CONSTANT);
let points: Vec<(Tick, f64)> = p
.objs(pid::POINT_LIST)
.iter()
.filter_map(|cp| {
let (n, d) = cp.rational(pid::CP_TIME).filter(|r| r.1 != 0)?;
let v = cp.data(pid::CP_VALUE).and_then(indirect_f64)?;
Some((Tick((len.0 as i128 * n as i128 / d as i128).clamp(-(1i128 << 56), 1i128 << 56) as i64), v))
})
.collect();
return (!points.is_empty()).then_some(Gain::Varying { linear, points });
}
None
}
fn clip(&mut self, c: &Obj, rate: (i64, i64), kind: CKind, master_key: Option<String>) -> CItem {
let len = units_to_ticks(len_of(c), rate.0, rate.1);
let start = units_to_ticks(c.i64(pid::START_TIME).unwrap_or(0), rate.0, rate.1);
let name = tagged_values(c, pid::COMPONENT_USER_COMMENTS).into_iter().find(|(k, _)| k == "Clip Name").map(|(_, v)| v).unwrap_or_default();
let (Some(id), Some(slot)) = (c.mob_id(pid::SOURCE_ID), c.u32(pid::SOURCE_MOB_SLOT_ID)) else { return CItem::Filler(len) };
if id == [0; 32] {
return CItem::Filler(len);
}
match self.resolve(id, slot, start, kind, master_key, 0) {
Some((source, t)) => CItem::Clip(CClip { len, source, start: t, gain: None, name }),
None => CItem::Filler(len),
}
}
/// Follow a source reference to a file source mob: (source index, file time).
fn resolve(&mut self, id: MobId, slot_id: u32, start: Tick, kind: CKind, master_key: Option<String>, depth: usize) -> Option<(usize, Tick)> {
if depth > 16 {
return None;
}
let Some(mob) = self.mobs.get(&id).copied() else {
self.report.warn("a clip references a mob that is not in the file (imported as a gap)");
return None;
};
let slot = mob.objs(pid::SLOTS).iter().find(|s| s.u32(pid::SLOT_ID) == Some(slot_id))?;
let rate = rate_of(slot, pid::EDIT_RATE).unwrap_or((25, 1));
let seg = slot.strong(pid::SEGMENT)?;
if mob.class == cls::COMPOSITION_MOB {
self.report.warn("nested compositions are not supported (imported as gaps)");
return None;
}
if mob.class == cls::MASTER_MOB {
let key = master_key.unwrap_or_else(|| hex(&id));
let clip = first_clip(seg)?;
let inner = units_to_ticks(clip.i64(pid::START_TIME).unwrap_or(0), rate.0, rate.1);
let next = clip.mob_id(pid::SOURCE_ID)?;
let next_slot = clip.u32(pid::SOURCE_MOB_SLOT_ID)?;
if next == [0; 32] {
return None;
}
let markers = mob
.objs(pid::SLOTS)
.iter()
.filter(|s| s.class == cls::EVENT_MOB_SLOT)
.flat_map(|s| s.strong(pid::SEGMENT).map(|g| markers_of(g, rate_of(s, pid::EVENT_EDIT_RATE).unwrap_or(rate))).unwrap_or_default())
.collect::<Vec<_>>();
let r = self.resolve(next, next_slot, start + inner, kind, Some(key), depth + 1)?;
let src = &mut self.doc.sources[r.0];
if src.markers.is_empty() {
src.markers = markers;
}
if src.name.is_empty()
&& let Some(n) = mob.string(pid::MOB_NAME)
{
src.name = n;
}
return Some(r);
}
// a source mob: file (has a file descriptor) or physical (tape / import)
let desc = mob.strong(pid::ESSENCE_DESCRIPTION);
let is_file = desc.is_some_and(|d| d.get(pid::SAMPLE_RATE).is_some() || d.get(pid::LOCATOR).is_some() || self.essence.contains_key(&id));
if !is_file {
return None;
}
let key = master_key.unwrap_or_else(|| hex(&id));
let skey = (id, slot_id, key.clone());
if let Some(&i) = self.sources.get(&skey) {
return Some((i, start));
}
let d = desc?;
let seg_kind = match data_kind(seg) {
DataKind::Picture => CKind::Picture,
DataKind::Sound => CKind::Sound,
_ => kind,
};
let path = d.objs(pid::LOCATOR).iter().find_map(|l| l.string(pid::URL_STRING)).map(|u| crate::common::file_url_to_path(&u));
let file_rate = rate_of(d, pid::SAMPLE_RATE).unwrap_or(rate);
let length =
d.i64(pid::FILE_LENGTH).map(|l| units_to_ticks(l, file_rate.0, file_rate.1)).unwrap_or_else(|| units_to_ticks(len_of(seg), rate.0, rate.1));
let sound_slots = mob.objs(pid::SLOTS).iter().filter(|s| s.strong(pid::SEGMENT).is_some_and(|g| data_kind(g) == DataKind::Sound)).count();
let file_channels = d.u32(pid::CHANNELS).unwrap_or(1).max(1);
let channel = (seg_kind == CKind::Sound && file_channels > 1 && sound_slots > 1)
.then(|| slot.u32(pid::PHYSICAL_TRACK_NUMBER).unwrap_or(slot_id).saturating_sub(1).min(file_channels - 1));
let mut layout = d.u8(pid::FRAME_LAYOUT).unwrap_or(0);
if layout > 4 {
layout = 0;
}
let stored_h = d.u32(pid::STORED_HEIGHT).unwrap_or(0);
let fr = FrameRate::new(file_rate.0, file_rate.1);
// timecode from the physical source mob
let tc = first_clip(seg).and_then(|clip| {
let tid = clip.mob_id(pid::SOURCE_ID)?;
let tape = self.mobs.get(&tid)?;
let tslot = tape.objs(pid::SLOTS).iter().find_map(|s| {
let g = s.strong(pid::SEGMENT)?;
let t = find_class(g, cls::TIMECODE)?;
Some((t.i64(pid::TC_START).unwrap_or(0), t.u16(pid::TC_FPS).unwrap_or(25).max(1) as i64, rate_of(s, pid::EDIT_RATE)))
})?;
let off = units_to_ticks(clip.i64(pid::START_TIME).unwrap_or(0), rate.0, rate.1);
let tcr = tslot.2.map(|r| FrameRate::new(r.0, r.1)).unwrap_or_else(|| FrameRate::new(tslot.1, 1));
Some((tslot.0 + tcr.frame_at(off), tcr))
});
let mut s = Source {
key,
name: mob.string(pid::MOB_NAME).unwrap_or_default(),
kind: seg_kind,
path,
channel,
channels: if channel.is_some() { 1 } else { file_channels },
file_channels,
width: d.u32(pid::STORED_WIDTH).unwrap_or(0),
height: if matches!(layout, 1 | 3) { stored_h * 2 } else { stored_h },
frame_rate: if seg_kind == CKind::Picture { fr } else { FrameRate::FPS_25 },
sample_rate: d.rational(pid::AUDIO_SAMPLING_RATE).filter(|r| r.1 > 0 && r.0 > 0).map(|r| (r.0 / r.1) as u32).unwrap_or(file_rate.0 as u32),
bits: d.u32(pid::QUANTIZATION_BITS).unwrap_or(16).clamp(8, 32) as u16,
length,
offset: Tick::ZERO,
start_tc: tc.map(|t| t.0),
tc_rate: tc.map(|t| t.1).unwrap_or(fr),
embedded: None,
markers: Vec::new(),
};
if s.kind == CKind::Sound {
s.frame_rate = FrameRate::FPS_25;
if let Some(data) = self.essence.get(&id) {
s.embedded = Some(data.to_vec());
// embedded essence may be a whole WAVE / AIFF file
if let Some((pcm, ch, sr, bits)) = crate::wav::parse_wav(data) {
s.embedded = Some(pcm);
s.file_channels = ch as u32;
s.channels = if s.channel.is_some() { 1 } else { ch as u32 };
s.sample_rate = sr;
s.bits = bits;
}
}
}
if s.kind == CKind::Picture && s.width == 0 {
s.width = 1920;
s.height = 1080;
}
let i = self.doc.sources.len();
self.doc.sources.push(s);
self.sources.insert(skey, i);
Some((i, start))
}
}
/// Drop `t` from the front of a track's items (slot origin).
fn trim_front(items: &mut Vec<CItem>, mut t: Tick) {
while t > Tick::ZERO && !items.is_empty() {
let l = items[0].len();
if matches!(items[0], CItem::Transition(_)) {
items.remove(0);
continue;
}
if l <= t {
items.remove(0);
t -= l;
} else {
match &mut items[0] {
CItem::Filler(x) => *x -= t,
CItem::Clip(c) => {
c.len -= t;
c.start += t;
}
CItem::Transition(_) => {}
}
t = Tick::ZERO;
}
}
}
/// Make the items exactly `len` long (an operation group / selector is as long as itself).
fn fit(items: &mut Vec<CItem>, len: Tick) {
let total = Tick(items.iter().fold(0i64, |a, i| if matches!(i, CItem::Transition(_)) { a.saturating_sub(i.len().0) } else { a.saturating_add(i.len().0) }));
if total == len || items.is_empty() {
if items.is_empty() {
items.push(CItem::Filler(len));
}
return;
}
if total < len {
items.push(CItem::Filler(len - total));
return;
}
let mut excess = total - len;
while excess > Tick::ZERO {
match items.last_mut() {
Some(CItem::Filler(x)) | Some(CItem::Clip(CClip { len: x, .. })) if *x > excess => {
*x -= excess;
excess = Tick::ZERO;
}
Some(CItem::Transition(_)) => {
items.pop();
}
Some(other) => {
excess -= other.len();
items.pop();
}
None => break,
}
}
}
fn first_clip(seg: &Obj) -> Option<&Obj> {
if seg.class == cls::SOURCE_CLIP {
return Some(seg);
}
if seg.class == cls::SEQUENCE {
return seg.objs(pid::COMPONENTS).iter().find_map(first_clip);
}
// essence group (choices 0x0501) / operation group inputs
seg.objs(0x0501).iter().find_map(first_clip).or_else(|| seg.objs(pid::INPUT_SEGMENTS).iter().find_map(first_clip))
}
fn find_class(seg: &Obj, class: Auid) -> Option<&Obj> {
if seg.class == class {
return Some(seg);
}
seg.objs(pid::COMPONENTS).iter().find_map(|c| find_class(c, class))
}
fn markers_of(seg: &Obj, rate: (i64, i64)) -> Vec<CMarker> {
let events: Vec<&Obj> = if seg.class == cls::SEQUENCE { seg.objs(pid::COMPONENTS).iter().collect() } else { vec![seg] };
events
.into_iter()
.filter(|e| e.class == cls::COMMENT_MARKER || e.class == cls::DESCRIPTIVE_MARKER || e.get(pid::POSITION).is_some())
.map(|e| {
let tags = tagged_values(e, pid::COMPONENT_USER_COMMENTS);
let tag = |n: &str| tags.iter().find(|(k, _)| k == n).map(|(_, v)| v.clone());
let comment = e.string(pid::COMMENT).unwrap_or_default();
let start = units_to_ticks(e.i64(pid::POSITION).unwrap_or(0), rate.0, rate.1);
CMarker {
start,
duration: units_to_ticks(e.i64(pid::LENGTH).unwrap_or(0).max(0), rate.0, rate.1),
name: tag("Name").unwrap_or_else(|| comment.clone()),
comment: tag("Comment").unwrap_or_else(|| if tags.is_empty() { String::new() } else { comment.clone() }),
color: tag("Color"),
}
})
.collect()
}
fn hex(b: &[u8]) -> String {
b.iter().map(|x| format!("{x:02x}")).collect()
}