1use crate::bytes::{Cursor, OutOfBounds};
44use crate::kv6::{compute_vis_dir, Kv6, Voxel};
45
46const MAGIC: [u8; 4] = *b"RVCL";
47const VERSION: u16 = 2;
49const VERSION_LEGACY: u16 = 1;
52
53const TAG_META: [u8; 4] = *b"META";
54const TAG_FRMS: [u8; 4] = *b"FRMS";
55const TAG_TIME: [u8; 4] = *b"TIME";
56
57const CHUNK_FLAG_DEFLATED: u8 = 0x01;
59const MAX_CHUNK_INFLATE: usize = 64 << 20; const MAX_CLIP_DIM: u32 = 4096;
70const DEFLATE_LEVEL: u8 = 8;
73
74const FRAME_KIND_KEY: u8 = 0;
75const FRAME_KIND_DELTA: u8 = 1;
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum LoopMode {
80 Loop,
82 Once,
84 PingPong,
86}
87
88impl LoopMode {
89 fn to_u8(self) -> u8 {
90 match self {
91 Self::Loop => 0,
92 Self::Once => 1,
93 Self::PingPong => 2,
94 }
95 }
96 fn from_u8(v: u8) -> Option<Self> {
97 match v {
98 0 => Some(Self::Loop),
99 1 => Some(Self::Once),
100 2 => Some(Self::PingPong),
101 _ => None,
102 }
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct VoxelFrame {
110 pub occupancy: Vec<u32>,
115 pub colors: Vec<u32>,
118 pub color_offsets: Vec<u32>,
121}
122
123impl VoxelFrame {
124 #[must_use]
138 #[allow(clippy::cast_possible_truncation)]
139 pub fn from_kv6(kv6: &Kv6) -> Self {
140 let dims = [kv6.xsiz, kv6.ysiz, kv6.zsiz];
141 let (nx, ny) = (dims[0] as usize, dims[1] as usize);
142 let cols = nx * ny;
143 let owpc = occ_words_per_col(dims) as usize;
144 let zmax = dims[2];
145
146 let mut per_col: Vec<Vec<(u16, u32)>> = vec![Vec::new(); cols];
152 let mut vi = 0usize;
153 for x in 0..nx {
154 let col_counts = kv6.ylen.get(x).map_or(&[][..], Vec::as_slice);
155 for (y, &cnt) in col_counts.iter().enumerate() {
156 let start = vi.min(kv6.voxels.len());
157 let end = (start + cnt as usize).min(kv6.voxels.len());
158 if y < ny {
159 let col = x + y * nx; for v in &kv6.voxels[start..end] {
161 if u32::from(v.z) < zmax {
162 per_col[col].push((v.z, v.col));
163 }
164 }
165 }
166 vi = end;
167 }
168 }
169
170 let mut occupancy = vec![0u32; cols * owpc];
171 let mut colors = Vec::new();
172 let mut color_offsets = Vec::with_capacity(cols + 1);
173 color_offsets.push(0u32);
174 for (col, run) in per_col.iter_mut().enumerate() {
175 run.sort_by_key(|&(z, _)| z);
176 for &(z, c) in run.iter() {
177 let zi = z as usize;
178 occupancy[col * owpc + zi / 32] |= 1u32 << (zi % 32);
179 colors.push(c);
180 }
181 color_offsets.push(colors.len() as u32);
182 }
183
184 Self {
185 occupancy,
186 colors,
187 color_offsets,
188 }
189 }
190
191 #[must_use]
198 #[allow(clippy::cast_possible_truncation)]
199 pub fn to_kv6(&self, dims: [u32; 3], pivot: [f32; 3]) -> Kv6 {
200 let (nx, ny) = (dims[0] as usize, dims[1] as usize);
201 let owpc = occ_words_per_col(dims) as usize;
202 let mut voxels = Vec::new();
203 let mut xlen = Vec::with_capacity(nx);
204 let mut ylen = Vec::with_capacity(nx);
205
206 for x in 0..nx {
209 let mut col_counts: Vec<u16> = Vec::with_capacity(ny);
210 let mut xcount = 0u32;
211 for y in 0..ny {
212 let col = x + y * nx;
213 let run = self.column_colors(col);
214 let occ = &self.occupancy[col * owpc..(col + 1) * owpc];
215 let before = voxels.len();
216 let mut ci = 0usize;
217 for z in 0..dims[2] {
218 if (occ[(z >> 5) as usize] >> (z & 31)) & 1 != 0 {
219 voxels.push(Voxel {
220 col: run[ci],
221 z: z as u16,
222 vis: 63,
223 dir: 0,
224 });
225 ci += 1;
226 }
227 }
228 let c = (voxels.len() - before) as u16;
229 col_counts.push(c);
230 xcount += u32::from(c);
231 }
232 xlen.push(xcount);
233 ylen.push(col_counts);
234 }
235
236 Kv6 {
237 xsiz: dims[0],
238 ysiz: dims[1],
239 zsiz: dims[2],
240 xpiv: pivot[0],
241 ypiv: pivot[1],
242 zpiv: pivot[2],
243 voxels,
244 xlen,
245 ylen,
246 palette: None,
247 }
248 }
249
250 #[must_use]
256 pub fn dirs(&self, dims: [u32; 3]) -> Vec<u32> {
257 frame_dirs(self, dims, occ_words_per_col(dims) as usize)
258 }
259
260 pub fn validate(&self, dims: [u32; 3]) -> Result<(), FrameError> {
268 let cols = (dims[0] as usize) * (dims[1] as usize);
269 let owpc = occ_words_per_col(dims) as usize;
270 if self.occupancy.len() != cols * owpc {
271 return Err(FrameError::OccupancyLen);
272 }
273 if self.color_offsets.len() != cols + 1 {
274 return Err(FrameError::OffsetsLen);
275 }
276 if self.color_offsets[0] != 0
277 || *self.color_offsets.last().unwrap() as usize != self.colors.len()
278 {
279 return Err(FrameError::OffsetsBounds);
280 }
281 for col in 0..cols {
282 let lo = self.color_offsets[col];
283 let hi = self.color_offsets[col + 1];
284 if hi < lo {
285 return Err(FrameError::OffsetsMonotonic);
286 }
287 let run = (hi - lo) as usize;
288 let mut popcount = 0usize;
289 for w in 0..owpc {
290 popcount += self.occupancy[col * owpc + w].count_ones() as usize;
291 }
292 if popcount != run {
293 return Err(FrameError::OccupancyColorMismatch(col));
294 }
295 }
296 Ok(())
297 }
298
299 fn column_colors(&self, col: usize) -> &[u32] {
301 &self.colors[self.color_offsets[col] as usize..self.color_offsets[col + 1] as usize]
302 }
303
304 fn column_occ(&self, col: usize, owpc: usize) -> &[u32] {
306 &self.occupancy[col * owpc..(col + 1) * owpc]
307 }
308}
309
310#[derive(Debug, Clone, PartialEq, Eq)]
313pub struct ColumnDelta {
314 pub col: u32,
316 pub occ: Vec<u32>,
319 pub colors: Vec<u32>,
322}
323
324#[derive(Debug, Clone, PartialEq, Eq)]
327pub enum EncodedFrame {
328 Key(VoxelFrame),
330 Delta(Vec<ColumnDelta>),
332}
333
334#[derive(Debug, Clone, PartialEq)]
338pub struct VoxelClip {
339 pub dims: [u32; 3],
342 pub pivot: [f32; 3],
345 pub voxel_world_size: f32,
348 pub loop_mode: LoopMode,
350 pub default_frame_ms: u32,
352 pub frames: Vec<EncodedFrame>,
354 pub durations: Vec<u32>,
356 pub extra_chunks: Vec<([u8; 4], Vec<u8>)>,
358}
359
360#[derive(Debug, Clone)]
364pub struct DecodedClip {
365 pub dims: [u32; 3],
368 pub pivot: [f32; 3],
371 pub voxel_world_size: f32,
374 pub occ_words_per_col: u32,
376 pub loop_mode: LoopMode,
378 pub frames: Vec<VoxelFrame>,
381 pub dirs: Vec<Vec<u32>>,
384 pub durations: Vec<u32>,
387}
388
389impl DecodedClip {
390 #[must_use]
392 pub fn frame_count(&self) -> usize {
393 self.frames.len()
394 }
395
396 #[must_use]
399 pub fn total_ms(&self) -> u32 {
400 self.durations
401 .iter()
402 .fold(0u32, |acc, &d| acc.saturating_add(d))
403 }
404
405 #[must_use]
408 pub fn pad_stats(&self) -> PadStats {
409 pad_stats(self.dims, &self.frames)
410 }
411
412 #[must_use]
421 pub fn frame_at(&self, elapsed_ms: u32) -> usize {
422 frame_at(&self.durations, self.loop_mode, elapsed_ms)
423 }
424}
425
426#[must_use]
436pub fn frame_at(durations: &[u32], loop_mode: LoopMode, elapsed_ms: u32) -> usize {
437 let n = durations.len();
438 if n <= 1 {
439 return 0;
440 }
441 let total: u64 = durations.iter().map(|&d| u64::from(d)).sum();
444 if total == 0 {
445 return 0;
446 }
447 let elapsed = u64::from(elapsed_ms);
448 let t = match loop_mode {
450 LoopMode::Loop => elapsed % total,
451 LoopMode::Once => elapsed.min(total - 1),
452 LoopMode::PingPong => {
453 let p = elapsed % (2 * total);
454 if p < total {
455 p
456 } else {
457 2 * total - 1 - p
459 }
460 }
461 };
462 let mut acc = 0u64;
464 for (i, &d) in durations.iter().enumerate() {
465 acc += u64::from(d);
466 if t < acc {
467 return i;
468 }
469 }
470 n - 1
471}
472
473#[must_use]
479pub fn duration_prefix_sums(durations: &[u32]) -> Vec<u64> {
480 let mut acc = 0u64;
481 durations
482 .iter()
483 .map(|&d| {
484 acc += u64::from(d);
485 acc
486 })
487 .collect()
488}
489
490#[must_use]
495pub fn frame_at_prefix(prefix: &[u64], loop_mode: LoopMode, elapsed_ms: u32) -> usize {
496 let n = prefix.len();
497 if n <= 1 {
498 return 0;
499 }
500 let total = prefix[n - 1];
501 if total == 0 {
502 return 0;
503 }
504 let elapsed = u64::from(elapsed_ms);
505 let t = match loop_mode {
506 LoopMode::Loop => elapsed % total,
507 LoopMode::Once => elapsed.min(total - 1),
508 LoopMode::PingPong => {
509 let p = elapsed % (2 * total);
510 if p < total {
511 p
512 } else {
513 2 * total - 1 - p
514 }
515 }
516 };
517 prefix.partition_point(|&acc| acc <= t).min(n - 1)
521}
522
523#[must_use]
525pub fn occ_words_per_col(dims: [u32; 3]) -> u32 {
526 dims[2].div_ceil(32).max(1)
527}
528
529#[derive(Debug, Clone, Copy, PartialEq, Eq)]
546pub struct PadStats {
547 pub dims: [u32; 3],
549 pub content_dims: [u32; 3],
553 pub solid_voxels: u64,
555}
556
557impl PadStats {
558 #[must_use]
563 pub fn pad_ratio(&self) -> f32 {
564 let content = vol(self.content_dims);
565 if content == 0 {
566 1.0
567 } else {
568 vol(self.dims) as f32 / content as f32
569 }
570 }
571
572 #[must_use]
576 pub fn is_wasteful(&self) -> bool {
577 self.pad_ratio() >= 2.0
578 }
579}
580
581fn vol(d: [u32; 3]) -> u64 {
582 u64::from(d[0]) * u64::from(d[1]) * u64::from(d[2])
583}
584
585#[must_use]
590pub fn pad_stats(dims: [u32; 3], frames: &[VoxelFrame]) -> PadStats {
591 let owpc = occ_words_per_col(dims) as usize;
592 let (mx, my, mz) = (dims[0], dims[1], dims[2]);
593 let mut min = [u32::MAX; 3];
594 let mut max = [0u32; 3];
595 let mut solid_voxels = 0u64;
596 let mut any = false;
597
598 for f in frames {
599 for y in 0..my {
600 for x in 0..mx {
601 let base = (x + y * mx) as usize * owpc;
602 let occ = match f.occupancy.get(base..base + owpc) {
603 Some(o) if o.iter().any(|&w| w != 0) => o,
604 _ => continue, };
606 for z in 0..mz {
607 if (occ[(z >> 5) as usize] >> (z & 31)) & 1 != 0 {
608 any = true;
609 solid_voxels += 1;
610 min[0] = min[0].min(x);
611 min[1] = min[1].min(y);
612 min[2] = min[2].min(z);
613 max[0] = max[0].max(x);
614 max[1] = max[1].max(y);
615 max[2] = max[2].max(z);
616 }
617 }
618 }
619 }
620 }
621
622 let content_dims = if any {
623 [
624 max[0] - min[0] + 1,
625 max[1] - min[1] + 1,
626 max[2] - min[2] + 1,
627 ]
628 } else {
629 [0; 3]
630 };
631 PadStats {
632 dims,
633 content_dims,
634 solid_voxels,
635 }
636}
637
638#[derive(Debug, Clone)]
652pub struct StreamingClip {
653 dims: [u32; 3],
654 pivot: [f32; 3],
655 voxel_world_size: f32,
656 loop_mode: LoopMode,
657 owpc: usize,
658 cols: usize,
659 frames: Vec<EncodedFrame>,
661 durations: Vec<u32>,
662 keyframes: Vec<usize>,
664 work_occ: Vec<u32>,
666 work_cols: Vec<Vec<u32>>,
667 current: usize,
669 cur_frame: VoxelFrame,
670 cur_dirs: Vec<u32>,
671}
672
673impl StreamingClip {
674 pub fn new(clip: &VoxelClip) -> Result<Self, DecodeError> {
681 if !matches!(clip.frames.first(), Some(EncodedFrame::Key(_))) {
682 return Err(DecodeError::DeltaBeforeKey);
683 }
684 let owpc = occ_words_per_col(clip.dims) as usize;
685 let cols = (clip.dims[0] as usize) * (clip.dims[1] as usize);
686 let keyframes = clip
687 .frames
688 .iter()
689 .enumerate()
690 .filter_map(|(i, f)| matches!(f, EncodedFrame::Key(_)).then_some(i))
691 .collect();
692 let durations = if clip.durations.is_empty() {
693 vec![clip.default_frame_ms; clip.frames.len()]
694 } else {
695 clip.durations.clone()
696 };
697 let mut s = Self {
698 dims: clip.dims,
699 pivot: clip.pivot,
700 voxel_world_size: clip.voxel_world_size,
701 loop_mode: clip.loop_mode,
702 owpc,
703 cols,
704 frames: clip.frames.clone(),
705 durations,
706 keyframes,
707 work_occ: vec![0u32; cols * owpc],
708 work_cols: vec![Vec::new(); cols],
709 current: 0,
710 cur_frame: VoxelFrame {
711 occupancy: Vec::new(),
712 colors: Vec::new(),
713 color_offsets: Vec::new(),
714 },
715 cur_dirs: Vec::new(),
716 };
717 s.reconstruct(0)?;
718 Ok(s)
719 }
720
721 #[must_use]
723 pub fn frame_count(&self) -> usize {
724 self.frames.len()
725 }
726 #[must_use]
728 pub fn dims(&self) -> [u32; 3] {
729 self.dims
730 }
731 #[must_use]
734 pub fn pivot(&self) -> [f32; 3] {
735 self.pivot
736 }
737 #[must_use]
739 pub fn voxel_world_size(&self) -> f32 {
740 self.voxel_world_size
741 }
742 #[must_use]
744 pub fn loop_mode(&self) -> LoopMode {
745 self.loop_mode
746 }
747 #[must_use]
749 pub fn durations(&self) -> &[u32] {
750 &self.durations
751 }
752 #[must_use]
754 pub fn current_index(&self) -> usize {
755 self.current
756 }
757 #[must_use]
759 pub fn current_frame(&self) -> &VoxelFrame {
760 &self.cur_frame
761 }
762 #[must_use]
765 pub fn current_dirs(&self) -> &[u32] {
766 &self.cur_dirs
767 }
768
769 pub fn seek(&mut self, frame: usize) -> Result<&VoxelFrame, DecodeError> {
777 let target = frame.min(self.frame_count() - 1);
778 if target != self.current || self.cur_frame.occupancy.is_empty() {
779 self.reconstruct(target)?;
780 }
781 Ok(&self.cur_frame)
782 }
783
784 fn keyframe_at_or_before(&self, target: usize) -> usize {
786 let pp = self.keyframes.partition_point(|&k| k <= target);
787 self.keyframes[pp - 1]
788 }
789
790 fn reconstruct(&mut self, target: usize) -> Result<(), DecodeError> {
792 let start = if target > self.current && !self.cur_frame.occupancy.is_empty() {
795 self.current + 1
796 } else {
797 let kf = self.keyframe_at_or_before(target);
798 let mut started = false;
799 apply_frame(
800 &self.frames[kf],
801 &mut self.work_occ,
802 &mut self.work_cols,
803 self.dims,
804 self.owpc,
805 self.cols,
806 &mut started,
807 )?;
808 kf + 1
809 };
810 let mut started = true;
811 for i in start..=target {
812 let ef = &self.frames[i];
814 apply_frame(
815 ef,
816 &mut self.work_occ,
817 &mut self.work_cols,
818 self.dims,
819 self.owpc,
820 self.cols,
821 &mut started,
822 )?;
823 }
824 self.current = target;
825 self.cur_frame = flatten(&self.work_occ, &self.work_cols, self.cols);
826 self.cur_frame
827 .validate(self.dims)
828 .map_err(DecodeError::Frame)?;
829 self.cur_dirs = frame_dirs(&self.cur_frame, self.dims, self.owpc);
830 Ok(())
831 }
832}
833
834const KEYFRAME_COST_PCT: usize = 60;
839
840fn column_diff(
844 prev: &VoxelFrame,
845 frame: &VoxelFrame,
846 cols: usize,
847 owpc: usize,
848) -> Vec<ColumnDelta> {
849 let mut changed = Vec::new();
850 for col in 0..cols {
851 if prev.column_occ(col, owpc) != frame.column_occ(col, owpc)
852 || prev.column_colors(col) != frame.column_colors(col)
853 {
854 changed.push(ColumnDelta {
855 col: col as u32,
856 occ: frame.column_occ(col, owpc).to_vec(),
857 colors: frame.column_colors(col).to_vec(),
858 });
859 }
860 }
861 changed
862}
863
864fn key_words(frame: &VoxelFrame) -> usize {
866 frame.occupancy.len() + frame.color_offsets.len() + frame.colors.len() + 3
868}
869
870fn delta_words(changed: &[ColumnDelta], owpc: usize) -> usize {
872 1 + changed
874 .iter()
875 .map(|d| 1 + owpc + 1 + d.colors.len())
876 .sum::<usize>()
877}
878
879impl VoxelClip {
880 #[must_use]
882 pub fn occ_words_per_col(&self) -> u32 {
883 occ_words_per_col(self.dims)
884 }
885
886 #[must_use]
888 pub fn frame_count(&self) -> usize {
889 self.frames.len()
890 }
891
892 pub fn from_kv6_frames(
907 frames: &[Kv6],
908 voxel_world_size: f32,
909 loop_mode: LoopMode,
910 durations: &[u32],
911 default_frame_ms: u32,
912 keyframe_interval: u32,
913 ) -> Result<Self, Kv6ImportError> {
914 let (dims, pivot, vframes) = Self::kv6_frames_prepare(frames)?;
915 Ok(Self::from_frames(
916 dims,
917 pivot,
918 voxel_world_size,
919 loop_mode,
920 &vframes,
921 durations,
922 default_frame_ms,
923 keyframe_interval,
924 ))
925 }
926
927 pub fn from_kv6_frames_auto(
936 frames: &[Kv6],
937 voxel_world_size: f32,
938 loop_mode: LoopMode,
939 durations: &[u32],
940 default_frame_ms: u32,
941 max_keyframe_gap: u32,
942 ) -> Result<Self, Kv6ImportError> {
943 let (dims, pivot, vframes) = Self::kv6_frames_prepare(frames)?;
944 Ok(Self::from_frames_auto(
945 dims,
946 pivot,
947 voxel_world_size,
948 loop_mode,
949 &vframes,
950 durations,
951 default_frame_ms,
952 max_keyframe_gap,
953 ))
954 }
955
956 #[allow(clippy::type_complexity)]
959 fn kv6_frames_prepare(
960 frames: &[Kv6],
961 ) -> Result<([u32; 3], [f32; 3], Vec<VoxelFrame>), Kv6ImportError> {
962 let Some(first) = frames.first() else {
963 return Err(Kv6ImportError::Empty);
964 };
965 let dims = [first.xsiz, first.ysiz, first.zsiz];
966 for (i, k) in frames.iter().enumerate() {
967 let d = [k.xsiz, k.ysiz, k.zsiz];
968 if d != dims {
969 return Err(Kv6ImportError::DimsMismatch {
970 frame: i,
971 dims: d,
972 expected: dims,
973 });
974 }
975 }
976 let pivot = [first.xpiv, first.ypiv, first.zpiv];
977 let vframes = frames.iter().map(VoxelFrame::from_kv6).collect();
978 Ok((dims, pivot, vframes))
979 }
980
981 #[must_use]
993 pub fn from_frames(
994 dims: [u32; 3],
995 pivot: [f32; 3],
996 voxel_world_size: f32,
997 loop_mode: LoopMode,
998 frames: &[VoxelFrame],
999 durations: &[u32],
1000 default_frame_ms: u32,
1001 keyframe_interval: u32,
1002 ) -> Self {
1003 for (i, f) in frames.iter().enumerate() {
1004 f.validate(dims)
1005 .unwrap_or_else(|e| panic!("frame {i} invalid: {e:?}"));
1006 }
1007 assert!(
1008 durations.is_empty() || durations.len() == frames.len(),
1009 "durations must be empty or one per frame",
1010 );
1011 let owpc = occ_words_per_col(dims) as usize;
1012 let cols = (dims[0] as usize) * (dims[1] as usize);
1013
1014 let mut encoded = Vec::with_capacity(frames.len());
1015 for (i, frame) in frames.iter().enumerate() {
1016 let is_key = i == 0 || (keyframe_interval != 0 && (i as u32) % keyframe_interval == 0);
1017 if is_key {
1018 encoded.push(EncodedFrame::Key(frame.clone()));
1019 } else {
1020 encoded.push(EncodedFrame::Delta(column_diff(
1021 &frames[i - 1],
1022 frame,
1023 cols,
1024 owpc,
1025 )));
1026 }
1027 }
1028
1029 Self {
1030 dims,
1031 pivot,
1032 voxel_world_size,
1033 loop_mode,
1034 default_frame_ms,
1035 frames: encoded,
1036 durations: durations.to_vec(),
1037 extra_chunks: Vec::new(),
1038 }
1039 }
1040
1041 #[must_use]
1054 pub fn from_frames_auto(
1055 dims: [u32; 3],
1056 pivot: [f32; 3],
1057 voxel_world_size: f32,
1058 loop_mode: LoopMode,
1059 frames: &[VoxelFrame],
1060 durations: &[u32],
1061 default_frame_ms: u32,
1062 max_keyframe_gap: u32,
1063 ) -> Self {
1064 for (i, f) in frames.iter().enumerate() {
1065 f.validate(dims)
1066 .unwrap_or_else(|e| panic!("frame {i} invalid: {e:?}"));
1067 }
1068 assert!(
1069 durations.is_empty() || durations.len() == frames.len(),
1070 "durations must be empty or one per frame",
1071 );
1072 let owpc = occ_words_per_col(dims) as usize;
1073 let cols = (dims[0] as usize) * (dims[1] as usize);
1074
1075 let mut encoded = Vec::with_capacity(frames.len());
1076 let mut since_key = 0u32;
1077 for (i, frame) in frames.iter().enumerate() {
1078 if i == 0 {
1079 encoded.push(EncodedFrame::Key(frame.clone()));
1080 since_key = 0;
1081 continue;
1082 }
1083 let changed = column_diff(&frames[i - 1], frame, cols, owpc);
1084 let gap_forces_key = max_keyframe_gap != 0 && since_key + 1 >= max_keyframe_gap;
1085 let delta_too_big =
1086 delta_words(&changed, owpc) * 100 >= key_words(frame) * KEYFRAME_COST_PCT;
1087 if gap_forces_key || delta_too_big {
1088 encoded.push(EncodedFrame::Key(frame.clone()));
1089 since_key = 0;
1090 } else {
1091 encoded.push(EncodedFrame::Delta(changed));
1092 since_key += 1;
1093 }
1094 }
1095
1096 Self {
1097 dims,
1098 pivot,
1099 voxel_world_size,
1100 loop_mode,
1101 default_frame_ms,
1102 frames: encoded,
1103 durations: durations.to_vec(),
1104 extra_chunks: Vec::new(),
1105 }
1106 }
1107
1108 pub fn decode(&self) -> Result<DecodedClip, DecodeError> {
1117 let owpc = occ_words_per_col(self.dims) as usize;
1118 let cols = (self.dims[0] as usize) * (self.dims[1] as usize);
1119
1120 let mut work_occ = vec![0u32; cols * owpc];
1123 let mut work_cols: Vec<Vec<u32>> = vec![Vec::new(); cols];
1124 let mut frames: Vec<VoxelFrame> = Vec::with_capacity(self.frames.len());
1125 let mut started = false;
1126
1127 for ef in &self.frames {
1128 apply_frame(
1129 ef,
1130 &mut work_occ,
1131 &mut work_cols,
1132 self.dims,
1133 owpc,
1134 cols,
1135 &mut started,
1136 )?;
1137 frames.push(flatten(&work_occ, &work_cols, cols));
1138 }
1139
1140 let mut dirs = Vec::with_capacity(frames.len());
1142 for f in &frames {
1143 f.validate(self.dims).map_err(DecodeError::Frame)?;
1144 dirs.push(frame_dirs(f, self.dims, owpc));
1145 }
1146
1147 let durations = if self.durations.is_empty() {
1148 vec![self.default_frame_ms; frames.len()]
1149 } else {
1150 self.durations.clone()
1151 };
1152
1153 Ok(DecodedClip {
1154 dims: self.dims,
1155 pivot: self.pivot,
1156 voxel_world_size: self.voxel_world_size,
1157 occ_words_per_col: owpc as u32,
1158 loop_mode: self.loop_mode,
1159 frames,
1160 dirs,
1161 durations,
1162 })
1163 }
1164
1165 #[must_use]
1168 pub fn serialize(&self) -> Vec<u8> {
1169 let mut out = Vec::new();
1170 out.extend_from_slice(&MAGIC);
1171 out.extend_from_slice(&VERSION.to_le_bytes());
1172
1173 write_chunk(&mut out, TAG_META, |b| {
1174 for v in self.dims {
1175 b.extend_from_slice(&v.to_le_bytes());
1176 }
1177 for v in self.pivot {
1178 b.extend_from_slice(&v.to_le_bytes());
1179 }
1180 b.extend_from_slice(&self.voxel_world_size.to_le_bytes());
1181 b.push(self.loop_mode.to_u8());
1182 b.extend_from_slice(&self.default_frame_ms.to_le_bytes());
1183 let fc = u32::try_from(self.frames.len()).expect("frame count fits u32");
1184 b.extend_from_slice(&fc.to_le_bytes());
1185 });
1186
1187 write_chunk(&mut out, TAG_FRMS, |b| {
1188 for ef in &self.frames {
1189 match ef {
1190 EncodedFrame::Key(frame) => {
1191 b.push(FRAME_KIND_KEY);
1192 write_u32_vec(b, &frame.occupancy);
1193 write_u32_vec(b, &frame.color_offsets);
1194 write_u32_vec(b, &frame.colors);
1195 }
1196 EncodedFrame::Delta(changed) => {
1197 b.push(FRAME_KIND_DELTA);
1198 let n = u32::try_from(changed.len()).expect("changed count fits u32");
1199 b.extend_from_slice(&n.to_le_bytes());
1200 for d in changed {
1201 b.extend_from_slice(&d.col.to_le_bytes());
1202 for w in &d.occ {
1204 b.extend_from_slice(&w.to_le_bytes());
1205 }
1206 write_u32_vec(b, &d.colors);
1207 }
1208 }
1209 }
1210 }
1211 });
1212
1213 if !self.durations.is_empty() {
1214 write_chunk(&mut out, TAG_TIME, |b| {
1215 for d in &self.durations {
1216 b.extend_from_slice(&d.to_le_bytes());
1217 }
1218 });
1219 }
1220
1221 for (tag, payload) in &self.extra_chunks {
1222 write_chunk(&mut out, *tag, |b| b.extend_from_slice(payload));
1223 }
1224
1225 out
1226 }
1227
1228 pub fn parse(bytes: &[u8]) -> Result<VoxelClip, ParseError> {
1234 let mut cur = Cursor::new(bytes);
1235 let magic = cur.read_bytes(4)?;
1236 if magic != MAGIC {
1237 return Err(ParseError::BadMagic {
1238 got: [magic[0], magic[1], magic[2], magic[3]],
1239 });
1240 }
1241 let version = cur.read_u16()?;
1242 if version != VERSION && version != VERSION_LEGACY {
1243 return Err(ParseError::UnsupportedVersion(version));
1244 }
1245 let has_flags = version >= VERSION;
1247
1248 let mut meta: Option<Vec<u8>> = None;
1249 let mut frms: Option<Vec<u8>> = None;
1250 let mut time: Option<Vec<u8>> = None;
1251 let mut extra_chunks = Vec::new();
1252 while cur.remaining() > 0 {
1253 let tag_buf = cur.read_bytes(4)?;
1254 let tag = [tag_buf[0], tag_buf[1], tag_buf[2], tag_buf[3]];
1255 let flags = if has_flags { cur.read_u8()? } else { 0 };
1256 let len = cur.read_u32()? as usize;
1257 let stored = cur.read_bytes(len)?;
1258 let payload = if flags & CHUNK_FLAG_DEFLATED != 0 {
1259 inflate_chunk(stored)?
1260 } else {
1261 stored.to_vec()
1262 };
1263 match tag {
1264 TAG_META => meta = Some(payload),
1265 TAG_FRMS => frms = Some(payload),
1266 TAG_TIME => time = Some(payload),
1267 _ => extra_chunks.push((tag, payload)),
1268 }
1269 }
1270
1271 let meta = meta.ok_or(ParseError::MissingChunk(TAG_META))?;
1272 let frms = frms.ok_or(ParseError::MissingChunk(TAG_FRMS))?;
1273
1274 let (dims, pivot, voxel_world_size, loop_mode, default_frame_ms, frame_count) =
1275 parse_meta(&meta)?;
1276 let frames = parse_frms(&frms, dims, frame_count)?;
1277 let durations = match time {
1278 Some(p) => parse_time(&p, frame_count)?,
1279 None => Vec::new(),
1280 };
1281
1282 Ok(VoxelClip {
1283 dims,
1284 pivot,
1285 voxel_world_size,
1286 loop_mode,
1287 default_frame_ms,
1288 frames,
1289 durations,
1290 extra_chunks,
1291 })
1292 }
1293}
1294
1295fn apply_frame(
1302 ef: &EncodedFrame,
1303 work_occ: &mut [u32],
1304 work_cols: &mut [Vec<u32>],
1305 dims: [u32; 3],
1306 owpc: usize,
1307 cols: usize,
1308 started: &mut bool,
1309) -> Result<(), DecodeError> {
1310 match ef {
1311 EncodedFrame::Key(frame) => {
1312 frame.validate(dims).map_err(DecodeError::Frame)?;
1313 work_occ.copy_from_slice(&frame.occupancy);
1314 for (col, wc) in work_cols.iter_mut().enumerate() {
1315 wc.clear();
1316 wc.extend_from_slice(frame.column_colors(col));
1317 }
1318 *started = true;
1319 }
1320 EncodedFrame::Delta(changed) => {
1321 if !*started {
1322 return Err(DecodeError::DeltaBeforeKey);
1323 }
1324 for d in changed {
1325 let col = d.col as usize;
1326 if col >= cols || d.occ.len() != owpc {
1327 return Err(DecodeError::ColumnOutOfRange(d.col));
1328 }
1329 work_occ[col * owpc..(col + 1) * owpc].copy_from_slice(&d.occ);
1330 work_cols[col].clear();
1331 work_cols[col].extend_from_slice(&d.colors);
1332 }
1333 }
1334 }
1335 Ok(())
1336}
1337
1338fn flatten(occ: &[u32], cols_colors: &[Vec<u32>], cols: usize) -> VoxelFrame {
1340 let mut color_offsets = Vec::with_capacity(cols + 1);
1341 let mut colors = Vec::new();
1342 for run in cols_colors {
1343 color_offsets.push(colors.len() as u32);
1344 colors.extend_from_slice(run);
1345 }
1346 color_offsets.push(colors.len() as u32);
1347 VoxelFrame {
1348 occupancy: occ.to_vec(),
1349 colors,
1350 color_offsets,
1351 }
1352}
1353
1354fn frame_dirs(frame: &VoxelFrame, dims: [u32; 3], owpc: usize) -> Vec<u32> {
1357 let (mx, my, mz) = (dims[0] as i64, dims[1] as i64, dims[2] as i64);
1358 let solid = |x: i64, y: i64, z: i64| -> bool {
1359 if x < 0 || y < 0 || z < 0 || x >= mx || y >= my || z >= mz {
1360 return false;
1361 }
1362 let col = (x + y * mx) as usize;
1363 let word = frame.occupancy[col * owpc + (z >> 5) as usize];
1364 (word >> (z & 31)) & 1 != 0
1365 };
1366 let mut dirs = Vec::with_capacity(frame.colors.len());
1367 for y in 0..my {
1368 for x in 0..mx {
1369 let col = (x + y * mx) as usize;
1370 for z in 0..mz {
1372 let word = frame.occupancy[col * owpc + (z >> 5) as usize];
1373 if (word >> (z & 31)) & 1 != 0 {
1374 let (_vis, dir) = compute_vis_dir(&solid, x, y, z);
1375 dirs.push(u32::from(dir));
1376 }
1377 }
1378 }
1379 }
1380 dirs
1381}
1382
1383fn write_chunk(out: &mut Vec<u8>, tag: [u8; 4], body: impl FnOnce(&mut Vec<u8>)) {
1390 let mut raw = Vec::new();
1391 body(&mut raw);
1392 out.extend_from_slice(&tag);
1393
1394 let deflated = miniz_oxide::deflate::compress_to_vec(&raw, DEFLATE_LEVEL);
1395 if deflated.len() + 4 < raw.len() {
1397 out.push(CHUNK_FLAG_DEFLATED);
1398 let len = u32::try_from(deflated.len() + 4).expect("chunk length fits u32");
1399 out.extend_from_slice(&len.to_le_bytes());
1400 let raw_len = u32::try_from(raw.len()).expect("raw length fits u32");
1401 out.extend_from_slice(&raw_len.to_le_bytes());
1402 out.extend_from_slice(&deflated);
1403 } else {
1404 out.push(0);
1405 let len = u32::try_from(raw.len()).expect("chunk length fits u32");
1406 out.extend_from_slice(&len.to_le_bytes());
1407 out.extend_from_slice(&raw);
1408 }
1409}
1410
1411fn inflate_chunk(payload: &[u8]) -> Result<Vec<u8>, ParseError> {
1415 if payload.len() < 4 {
1416 return Err(ParseError::BadDeflate);
1417 }
1418 let raw_len = u32::from_le_bytes([payload[0], payload[1], payload[2], payload[3]]) as usize;
1419 if raw_len > MAX_CHUNK_INFLATE {
1423 return Err(ParseError::BadDeflate);
1424 }
1425 let out = miniz_oxide::inflate::decompress_to_vec_with_limit(&payload[4..], raw_len)
1426 .map_err(|_| ParseError::BadDeflate)?;
1427 if out.len() != raw_len {
1428 return Err(ParseError::BadDeflate);
1429 }
1430 Ok(out)
1431}
1432
1433fn write_u32_vec(out: &mut Vec<u8>, v: &[u32]) {
1435 let n = u32::try_from(v.len()).expect("u32 array length fits u32");
1436 out.extend_from_slice(&n.to_le_bytes());
1437 for w in v {
1438 out.extend_from_slice(&w.to_le_bytes());
1439 }
1440}
1441
1442fn read_u32_vec(cur: &mut Cursor) -> Result<Vec<u32>, ParseError> {
1443 let n = cur.read_u32()? as usize;
1444 let mut v = Vec::with_capacity(cur.clamped_capacity(n, 4));
1448 for _ in 0..n {
1449 v.push(cur.read_u32()?);
1450 }
1451 Ok(v)
1452}
1453
1454#[allow(clippy::type_complexity)]
1455fn parse_meta(payload: &[u8]) -> Result<([u32; 3], [f32; 3], f32, LoopMode, u32, u32), ParseError> {
1456 let mut cur = Cursor::new(payload);
1457 let dims = [cur.read_u32()?, cur.read_u32()?, cur.read_u32()?];
1458 if dims.iter().any(|&d| d == 0 || d > MAX_CLIP_DIM) {
1461 return Err(ParseError::BadDims { dims });
1462 }
1463 let pivot = [cur.read_f32()?, cur.read_f32()?, cur.read_f32()?];
1464 let voxel_world_size = cur.read_f32()?;
1465 let loop_mode = LoopMode::from_u8(cur.read_u8()?).ok_or(ParseError::BadLoopMode)?;
1466 let default_frame_ms = cur.read_u32()?;
1467 let frame_count = cur.read_u32()?;
1468 Ok((
1469 dims,
1470 pivot,
1471 voxel_world_size,
1472 loop_mode,
1473 default_frame_ms,
1474 frame_count,
1475 ))
1476}
1477
1478fn parse_frms(
1479 payload: &[u8],
1480 dims: [u32; 3],
1481 frame_count: u32,
1482) -> Result<Vec<EncodedFrame>, ParseError> {
1483 let owpc = occ_words_per_col(dims) as usize;
1484 let mut cur = Cursor::new(payload);
1485 let mut frames = Vec::with_capacity(frame_count as usize);
1486 for _ in 0..frame_count {
1487 let kind = cur.read_u8()?;
1488 match kind {
1489 FRAME_KIND_KEY => {
1490 let occupancy = read_u32_vec(&mut cur)?;
1491 let color_offsets = read_u32_vec(&mut cur)?;
1492 let colors = read_u32_vec(&mut cur)?;
1493 frames.push(EncodedFrame::Key(VoxelFrame {
1494 occupancy,
1495 colors,
1496 color_offsets,
1497 }));
1498 }
1499 FRAME_KIND_DELTA => {
1500 let n = cur.read_u32()? as usize;
1501 let mut changed = Vec::with_capacity(n);
1502 for _ in 0..n {
1503 let col = cur.read_u32()?;
1504 let mut occ = Vec::with_capacity(owpc);
1505 for _ in 0..owpc {
1506 occ.push(cur.read_u32()?);
1507 }
1508 let colors = read_u32_vec(&mut cur)?;
1509 changed.push(ColumnDelta { col, occ, colors });
1510 }
1511 frames.push(EncodedFrame::Delta(changed));
1512 }
1513 other => return Err(ParseError::BadFrameKind(other)),
1514 }
1515 }
1516 Ok(frames)
1517}
1518
1519fn parse_time(payload: &[u8], frame_count: u32) -> Result<Vec<u32>, ParseError> {
1520 let mut cur = Cursor::new(payload);
1521 let mut durations = Vec::with_capacity(frame_count as usize);
1522 for _ in 0..frame_count {
1523 durations.push(cur.read_u32()?);
1524 }
1525 Ok(durations)
1526}
1527
1528#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1532pub enum Kv6ImportError {
1533 Empty,
1535 DimsMismatch {
1537 frame: usize,
1539 dims: [u32; 3],
1541 expected: [u32; 3],
1543 },
1544}
1545
1546#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1548pub enum FrameError {
1549 OccupancyLen,
1551 OffsetsLen,
1553 OffsetsBounds,
1556 OffsetsMonotonic,
1559 OccupancyColorMismatch(usize),
1561}
1562
1563#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1565pub enum ParseError {
1566 BadMagic {
1568 got: [u8; 4],
1570 },
1571 UnsupportedVersion(u16),
1574 Truncated,
1576 MissingChunk([u8; 4]),
1578 BadLoopMode,
1580 BadFrameKind(u8),
1582 BadDeflate,
1585 BadDims {
1588 dims: [u32; 3],
1590 },
1591}
1592
1593impl From<OutOfBounds> for ParseError {
1594 fn from(_: OutOfBounds) -> Self {
1595 ParseError::Truncated
1596 }
1597}
1598
1599#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1601pub enum DecodeError {
1602 DeltaBeforeKey,
1604 ColumnOutOfRange(u32),
1606 Frame(FrameError),
1608}
1609
1610#[cfg(test)]
1611mod tests {
1612 use super::*;
1613
1614 use crate::color::VoxColor;
1615
1616 fn frame_from_fn(
1619 dims: [u32; 3],
1620 fill: impl Fn(u32, u32, u32) -> Option<VoxColor>,
1621 ) -> VoxelFrame {
1622 let owpc = occ_words_per_col(dims) as usize;
1623 let cols = (dims[0] as usize) * (dims[1] as usize);
1624 let mut occupancy = vec![0u32; cols * owpc];
1625 let mut color_offsets = vec![0u32; cols + 1];
1626 let mut colors = Vec::new();
1627 for y in 0..dims[1] {
1628 for x in 0..dims[0] {
1629 let col = (x + y * dims[0]) as usize;
1630 color_offsets[col] = colors.len() as u32;
1631 for z in 0..dims[2] {
1632 if let Some(c) = fill(x, y, z) {
1633 occupancy[col * owpc + (z >> 5) as usize] |= 1u32 << (z & 31);
1634 colors.push(c.0);
1635 }
1636 }
1637 }
1638 }
1639 color_offsets[cols] = colors.len() as u32;
1640 VoxelFrame {
1641 occupancy,
1642 colors,
1643 color_offsets,
1644 }
1645 }
1646
1647 fn flame_clip(
1651 dims: [u32; 3],
1652 n_frames: u32,
1653 keyframe_interval: u32,
1654 ) -> (VoxelClip, Vec<VoxelFrame>) {
1655 let frames: Vec<VoxelFrame> = (0..n_frames)
1656 .map(|fi| {
1657 frame_from_fn(dims, |x, y, z| {
1658 let cx = dims[0] / 2;
1659 let cy = dims[1] / 2;
1660 let stem = x == cx && y == cy && z < dims[2] - 2;
1662 let tip = x == cx && y == cy && z == dims[2] - 2 - (fi % 2);
1664 if stem || tip {
1665 Some(VoxColor(0x80FF_8000 | (fi & 0xF))) } else {
1667 None
1668 }
1669 })
1670 })
1671 .collect();
1672 let clip = VoxelClip::from_frames(
1673 dims,
1674 [
1675 dims[0] as f32 * 0.5,
1676 dims[1] as f32 * 0.5,
1677 dims[2] as f32 * 0.5,
1678 ],
1679 1.0,
1680 LoopMode::Loop,
1681 &frames,
1682 &[],
1683 33,
1684 keyframe_interval,
1685 );
1686 (clip, frames)
1687 }
1688
1689 #[test]
1690 fn occ_words_per_col_matches_sprite_model() {
1691 assert_eq!(occ_words_per_col([8, 8, 1]), 1);
1692 assert_eq!(occ_words_per_col([8, 8, 32]), 1);
1693 assert_eq!(occ_words_per_col([8, 8, 33]), 2);
1694 assert_eq!(occ_words_per_col([8, 8, 256]), 8);
1695 }
1696
1697 #[test]
1698 fn frame_validate_catches_mismatch() {
1699 let dims = [4, 4, 8];
1700 let mut f = frame_from_fn(dims, |x, y, z| {
1701 (x == 0 && y == 0 && z < 3).then_some(VoxColor(0x8000_00FF))
1702 });
1703 assert!(f.validate(dims).is_ok());
1704 f.occupancy[0] &= !1u32;
1707 assert!(matches!(
1708 f.validate(dims),
1709 Err(FrameError::OccupancyColorMismatch(0))
1710 ));
1711 }
1712
1713 #[test]
1714 fn decode_reconstructs_every_frame() {
1715 let dims = [9, 9, 40];
1716 let (clip, original) = flame_clip(dims, 8, 4);
1717 let decoded = clip.decode().expect("decode");
1718 assert_eq!(decoded.frame_count(), original.len());
1719 for (i, (got, want)) in decoded.frames.iter().zip(&original).enumerate() {
1720 assert_eq!(got, want, "frame {i} mismatch");
1721 assert_eq!(
1723 decoded.dirs[i].len(),
1724 got.colors.len(),
1725 "frame {i} dirs len"
1726 );
1727 }
1728 }
1729
1730 #[test]
1731 fn diff_frames_are_smaller_than_keyframes() {
1732 let dims = [9, 9, 40];
1733 let (clip, _) = flame_clip(dims, 8, 0); let keys = clip
1735 .frames
1736 .iter()
1737 .filter(|f| matches!(f, EncodedFrame::Key(_)))
1738 .count();
1739 assert_eq!(keys, 1, "keyframe_interval=0 ⇒ exactly one keyframe");
1740 for f in &clip.frames {
1743 if let EncodedFrame::Delta(changed) = f {
1744 assert!(
1745 changed.len() < (dims[0] * dims[1]) as usize,
1746 "delta should be sparse, got {} columns",
1747 changed.len()
1748 );
1749 }
1750 }
1751 }
1752
1753 #[test]
1754 fn serialize_parse_round_trips() {
1755 let dims = [9, 9, 40];
1756 let (clip, _) = flame_clip(dims, 8, 4);
1757 let bytes = clip.serialize();
1758 let parsed = VoxelClip::parse(&bytes).expect("parse");
1759 assert_eq!(parsed, clip);
1760 assert_eq!(parsed.serialize(), bytes);
1762 let a = clip.decode().expect("decode a");
1764 let b = parsed.decode().expect("decode b");
1765 assert_eq!(a.frames, b.frames);
1766 }
1767
1768 #[test]
1769 fn durations_default_when_time_chunk_absent() {
1770 let dims = [4, 4, 8];
1771 let (clip, _) = flame_clip(dims, 4, 2);
1772 assert!(clip.durations.is_empty());
1773 let decoded = clip.decode().expect("decode");
1774 assert_eq!(decoded.durations, vec![33; 4]);
1775 assert_eq!(decoded.total_ms(), 33 * 4);
1776 }
1777
1778 #[test]
1779 fn explicit_durations_round_trip() {
1780 let dims = [4, 4, 8];
1781 let frames: Vec<VoxelFrame> = (0..3)
1782 .map(|fi| {
1783 frame_from_fn(dims, move |x, y, z| {
1784 (x == 0 && y == 0 && z == fi).then_some(VoxColor(0x8011_2233))
1785 })
1786 })
1787 .collect();
1788 let clip = VoxelClip::from_frames(
1789 dims,
1790 [0.0; 3],
1791 1.0,
1792 LoopMode::Once,
1793 &frames,
1794 &[10, 20, 30],
1795 33,
1796 0,
1797 );
1798 let parsed = VoxelClip::parse(&clip.serialize()).expect("parse");
1799 assert_eq!(parsed.durations, vec![10, 20, 30]);
1800 assert_eq!(parsed.decode().unwrap().durations, vec![10, 20, 30]);
1801 assert_eq!(parsed.loop_mode, LoopMode::Once);
1802 }
1803
1804 #[test]
1805 fn unknown_chunks_preserved() {
1806 let dims = [4, 4, 8];
1807 let (mut clip, _) = flame_clip(dims, 2, 0);
1808 clip.extra_chunks.push((*b"XTRA", vec![1, 2, 3, 4, 5]));
1809 let parsed = VoxelClip::parse(&clip.serialize()).expect("parse");
1810 assert_eq!(parsed.extra_chunks, vec![(*b"XTRA", vec![1, 2, 3, 4, 5])]);
1811 }
1812
1813 #[test]
1814 fn bad_magic_and_version_rejected() {
1815 let dims = [4, 4, 8];
1816 let (clip, _) = flame_clip(dims, 2, 0);
1817 let mut bytes = clip.serialize();
1818 let good = bytes.clone();
1819 bytes[0] = b'X';
1820 assert!(matches!(
1821 VoxelClip::parse(&bytes),
1822 Err(ParseError::BadMagic { .. })
1823 ));
1824 let mut v = good.clone();
1825 v[4] = 9; assert!(matches!(
1827 VoxelClip::parse(&v),
1828 Err(ParseError::UnsupportedVersion(_))
1829 ));
1830 }
1831
1832 #[test]
1833 fn frame_at_honours_loop_modes() {
1834 let dims = [4, 4, 8];
1836 let frames: Vec<VoxelFrame> = (0..3)
1837 .map(|fi| {
1838 frame_from_fn(dims, move |x, y, z| {
1839 (x == 0 && y == 0 && z == fi).then_some(VoxColor(0x8011_2233))
1840 })
1841 })
1842 .collect();
1843 let mk = |mode| {
1844 VoxelClip::from_frames(dims, [0.0; 3], 1.0, mode, &frames, &[10, 10, 10], 33, 0)
1845 .decode()
1846 .unwrap()
1847 };
1848
1849 let loop_c = mk(LoopMode::Loop);
1850 assert_eq!(loop_c.frame_at(0), 0);
1851 assert_eq!(loop_c.frame_at(9), 0);
1852 assert_eq!(loop_c.frame_at(10), 1);
1853 assert_eq!(loop_c.frame_at(25), 2);
1854 assert_eq!(loop_c.frame_at(30), 0, "wraps at total");
1855 assert_eq!(loop_c.frame_at(45), 1);
1856
1857 let once = mk(LoopMode::Once);
1858 assert_eq!(once.frame_at(25), 2);
1859 assert_eq!(once.frame_at(1000), 2, "holds the last frame");
1860
1861 let ping = mk(LoopMode::PingPong);
1862 assert_eq!(ping.frame_at(5), 0);
1863 assert_eq!(ping.frame_at(25), 2);
1864 assert_eq!(ping.frame_at(35), 2, "mirror: 35→ frame 2");
1865 assert_eq!(ping.frame_at(55), 0, "mirror back to 0 near 2·total");
1866 }
1867
1868 #[test]
1869 fn delta_before_key_rejected() {
1870 let dims = [4, 4, 8];
1871 let clip = VoxelClip {
1872 dims,
1873 pivot: [0.0; 3],
1874 voxel_world_size: 1.0,
1875 loop_mode: LoopMode::Loop,
1876 default_frame_ms: 33,
1877 frames: vec![EncodedFrame::Delta(Vec::new())],
1878 durations: Vec::new(),
1879 extra_chunks: Vec::new(),
1880 };
1881 assert!(matches!(clip.decode(), Err(DecodeError::DeltaBeforeKey)));
1882 }
1883
1884 fn isolated_fill(x: u32, y: u32, z: u32) -> Option<crate::color::VoxColor> {
1891 (x % 2 == 0 && y % 2 == 0 && z % 2 == 0).then_some(crate::color::VoxColor(
1892 0x8000_0000 | (x << 16) | (y << 8) | z,
1893 ))
1894 }
1895
1896 #[test]
1897 fn from_kv6_matches_dense_reference() {
1898 let dims = [3u32, 2, 41];
1901 let kv6 = Kv6::from_fn(dims[0], dims[1], dims[2], isolated_fill);
1902 let imported = VoxelFrame::from_kv6(&kv6);
1903 let expected = frame_from_fn(dims, isolated_fill);
1904 assert_eq!(imported, expected);
1905 imported.validate(dims).expect("imported frame is valid");
1906 }
1907
1908 #[test]
1909 fn from_kv6_packs_z_across_word_boundary() {
1910 let kv6 = Kv6::from_fn(1, 1, 41, |_, _, z| match z {
1912 0 => Some(VoxColor(0x80FF_0000)),
1913 5 => Some(VoxColor(0x8000_FF00)),
1914 33 => Some(VoxColor(0x8000_00FF)),
1915 40 => Some(VoxColor(0x80FF_FF00)),
1916 _ => None,
1917 });
1918 let f = VoxelFrame::from_kv6(&kv6);
1919 assert_eq!(f.occupancy, vec![(1 << 0) | (1 << 5), (1 << 1) | (1 << 8)]);
1921 assert_eq!(
1923 f.colors,
1924 vec![0x80FF_0000, 0x8000_FF00, 0x8000_00FF, 0x80FF_FF00]
1925 );
1926 assert_eq!(f.color_offsets, vec![0, 4]);
1927 f.validate([1, 1, 41]).expect("valid");
1928 }
1929
1930 #[test]
1931 fn from_kv6_frames_round_trips_through_clip() {
1932 let dims = [2u32, 2, 3];
1933 let ka = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
1935 (z == 0).then_some(VoxColor(0x80FF_0000))
1936 });
1937 let kb = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
1938 (z == 2).then_some(VoxColor(0x8000_FF00))
1939 });
1940 let clip = VoxelClip::from_kv6_frames(
1941 &[ka.clone(), kb.clone()],
1942 2.0,
1943 LoopMode::Loop,
1944 &[100, 200],
1945 0,
1946 0,
1947 )
1948 .expect("import");
1949 assert_eq!(clip.dims, dims);
1950 assert_eq!(clip.voxel_world_size, 2.0);
1951 assert_eq!(clip.pivot, [ka.xpiv, ka.ypiv, ka.zpiv]);
1952 assert_eq!(clip.durations, vec![100, 200]);
1953
1954 let decoded = clip.decode().expect("decode");
1955 assert_eq!(decoded.frames.len(), 2);
1956 assert_eq!(decoded.frames[0], VoxelFrame::from_kv6(&ka));
1957 assert_eq!(decoded.frames[1], VoxelFrame::from_kv6(&kb));
1958 }
1959
1960 #[test]
1961 fn from_kv6_frames_rejects_empty() {
1962 let err = VoxelClip::from_kv6_frames(&[], 1.0, LoopMode::Loop, &[], 50, 0)
1963 .expect_err("empty must fail");
1964 assert_eq!(err, Kv6ImportError::Empty);
1965 }
1966
1967 #[test]
1968 fn from_kv6_frames_rejects_dims_mismatch() {
1969 let ka = Kv6::from_fn(2, 2, 2, |_, _, z| (z == 0).then_some(VoxColor(0x80FF_FFFF)));
1970 let kb = Kv6::from_fn(3, 2, 2, |_, _, z| (z == 0).then_some(VoxColor(0x80FF_FFFF)));
1971 let err = VoxelClip::from_kv6_frames(&[ka, kb], 1.0, LoopMode::Loop, &[], 50, 0)
1972 .expect_err("mismatch must fail");
1973 assert_eq!(
1974 err,
1975 Kv6ImportError::DimsMismatch {
1976 frame: 1,
1977 dims: [3, 2, 2],
1978 expected: [2, 2, 2],
1979 }
1980 );
1981 }
1982
1983 #[test]
1984 fn to_kv6_inverts_from_kv6() {
1985 let dims = [3u32, 2, 40];
1988 let frame = frame_from_fn(dims, |x, y, z| {
1989 (z <= (x + y) * 6 + 3).then_some(VoxColor(0x8000_0000 | (z << 8) | (x * 16 + y)))
1990 });
1991 let kv6 = frame.to_kv6(dims, [1.0, 0.5, 20.0]);
1992 assert_eq!([kv6.xsiz, kv6.ysiz, kv6.zsiz], dims);
1993 assert_eq!([kv6.xpiv, kv6.ypiv, kv6.zpiv], [1.0, 0.5, 20.0]);
1994 assert_eq!(VoxelFrame::from_kv6(&kv6), frame);
1996 }
1997
1998 #[test]
1999 fn voxel_frame_dirs_match_decoded() {
2000 let dims = [4u32, 3, 8];
2004 let frame = frame_from_fn(dims, |x, y, z| {
2005 (z <= x + y).then_some(VoxColor(0x80FF_0000))
2006 });
2007 let clip = VoxelClip::from_frames(
2008 dims,
2009 [0.0; 3],
2010 1.0,
2011 LoopMode::Loop,
2012 std::slice::from_ref(&frame),
2013 &[],
2014 33,
2015 0,
2016 );
2017 let decoded = clip.decode().unwrap();
2018 assert_eq!(frame.dirs(dims), decoded.dirs[0]);
2019 }
2020
2021 #[test]
2024 fn compressed_clip_round_trips_and_shrinks() {
2025 let dims = [16u32, 16, 32];
2028 let frame = frame_from_fn(dims, |_, _, _| Some(VoxColor(0x80AB_CDEF)));
2029 let clip = VoxelClip::from_frames(
2030 dims,
2031 [8.0, 8.0, 16.0],
2032 1.0,
2033 LoopMode::Loop,
2034 &[frame],
2035 &[],
2036 33,
2037 0,
2038 );
2039 let bytes = clip.serialize();
2040 let raw_colors_bytes = (dims[0] * dims[1] * dims[2]) as usize * 4;
2043 assert!(
2044 bytes.len() < raw_colors_bytes / 4,
2045 "expected compression: {} serialized bytes vs {raw_colors_bytes} raw colour bytes",
2046 bytes.len(),
2047 );
2048 assert_eq!(&bytes[4..6], &VERSION.to_le_bytes());
2051 let parsed = VoxelClip::parse(&bytes).expect("parse");
2052 assert_eq!(parsed, clip);
2053 assert_eq!(parsed.serialize(), bytes);
2054 }
2055
2056 fn serialize_v1(clip: &VoxelClip) -> Vec<u8> {
2059 fn chunk(out: &mut Vec<u8>, tag: [u8; 4], payload: &[u8]) {
2060 out.extend_from_slice(&tag);
2061 out.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2062 out.extend_from_slice(payload);
2063 }
2064 fn u32_vec(out: &mut Vec<u8>, v: &[u32]) {
2065 out.extend_from_slice(&(v.len() as u32).to_le_bytes());
2066 for w in v {
2067 out.extend_from_slice(&w.to_le_bytes());
2068 }
2069 }
2070 let mut out = Vec::new();
2071 out.extend_from_slice(b"RVCL");
2072 out.extend_from_slice(&1u16.to_le_bytes());
2073
2074 let mut meta = Vec::new();
2075 for v in clip.dims {
2076 meta.extend_from_slice(&v.to_le_bytes());
2077 }
2078 for v in clip.pivot {
2079 meta.extend_from_slice(&v.to_le_bytes());
2080 }
2081 meta.extend_from_slice(&clip.voxel_world_size.to_le_bytes());
2082 meta.push(clip.loop_mode.to_u8());
2083 meta.extend_from_slice(&clip.default_frame_ms.to_le_bytes());
2084 meta.extend_from_slice(&(clip.frames.len() as u32).to_le_bytes());
2085 chunk(&mut out, *b"META", &meta);
2086
2087 let mut frms = Vec::new();
2088 for ef in &clip.frames {
2089 let EncodedFrame::Key(f) = ef else {
2090 panic!("serialize_v1 test helper handles keyframes only");
2091 };
2092 frms.push(FRAME_KIND_KEY);
2093 u32_vec(&mut frms, &f.occupancy);
2094 u32_vec(&mut frms, &f.color_offsets);
2095 u32_vec(&mut frms, &f.colors);
2096 }
2097 chunk(&mut out, *b"FRMS", &frms);
2098 out
2099 }
2100
2101 #[test]
2102 fn legacy_v1_file_still_parses() {
2103 let dims = [2u32, 2, 3];
2104 let frame = frame_from_fn(dims, |_, _, z| (z == 0).then_some(VoxColor(0x80FF_0000)));
2105 let clip =
2106 VoxelClip::from_frames(dims, [0.0; 3], 1.0, LoopMode::Once, &[frame], &[], 50, 0);
2107 let v1 = serialize_v1(&clip);
2108 assert_eq!(&v1[4..6], &1u16.to_le_bytes(), "helper writes version 1");
2109 let parsed = VoxelClip::parse(&v1).expect("v1 must still parse");
2110 assert_eq!(parsed, clip);
2111 }
2112
2113 #[test]
2114 fn bad_deflate_payload_is_rejected() {
2115 let mut bytes = Vec::new();
2117 bytes.extend_from_slice(b"RVCL");
2118 bytes.extend_from_slice(&VERSION.to_le_bytes());
2119 bytes.extend_from_slice(b"META");
2120 bytes.push(CHUNK_FLAG_DEFLATED);
2121 let payload = [99u8, 0, 0, 0, 0xDE, 0xAD, 0xBE, 0xEF]; bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2123 bytes.extend_from_slice(&payload);
2124 assert_eq!(VoxelClip::parse(&bytes), Err(ParseError::BadDeflate));
2125 }
2126
2127 fn build_varied_clip() -> VoxelClip {
2134 let dims = [4u32, 3, 40];
2135 let frames: Vec<VoxelFrame> = (0..7u32)
2136 .map(|i| {
2137 let h = 5 + i * 5;
2138 frame_from_fn(dims, move |_x, _y, z| {
2139 (z < h).then_some(VoxColor(0x8000_0000 | (i * 0x10)))
2140 })
2141 })
2142 .collect();
2143 VoxelClip::from_frames(
2144 dims,
2145 [2.0, 1.5, 20.0],
2146 1.0,
2147 LoopMode::Loop,
2148 &frames,
2149 &[],
2150 33,
2151 3,
2152 )
2153 }
2154
2155 #[test]
2156 fn streaming_matches_decoded_forward_and_random() {
2157 let clip = build_varied_clip();
2158 let decoded = clip.decode().expect("decode");
2159 let mut stream = StreamingClip::new(&clip).expect("stream");
2160 assert_eq!(stream.frame_count(), decoded.frames.len());
2161 assert_eq!(stream.dims(), decoded.dims);
2162 assert_eq!(stream.pivot(), decoded.pivot);
2163
2164 for (i, want) in decoded.frames.iter().enumerate() {
2166 let got = stream.seek(i).expect("seek").clone();
2167 assert_eq!(&got, want, "frame {i} (forward)");
2168 assert_eq!(
2169 stream.current_dirs(),
2170 decoded.dirs[i].as_slice(),
2171 "dirs {i}"
2172 );
2173 assert_eq!(stream.current_index(), i);
2174 }
2175 for &i in &[6usize, 0, 4, 1, 5, 2, 3, 0, 6] {
2177 let got = stream.seek(i).expect("seek").clone();
2178 assert_eq!(&got, &decoded.frames[i], "frame {i} (random)");
2179 assert_eq!(stream.current_dirs(), decoded.dirs[i].as_slice());
2180 }
2181 }
2182
2183 #[test]
2184 fn streaming_seek_clamps_past_end() {
2185 let clip = build_varied_clip();
2186 let decoded = clip.decode().unwrap();
2187 let mut stream = StreamingClip::new(&clip).unwrap();
2188 let last = decoded.frames.len() - 1;
2189 let got = stream.seek(999).unwrap().clone();
2190 assert_eq!(got, decoded.frames[last]);
2191 assert_eq!(stream.current_index(), last);
2192 }
2193
2194 #[test]
2195 fn streaming_rejects_empty_and_delta_first() {
2196 let dims = [1u32, 1, 1];
2197 let mk = |frames: Vec<EncodedFrame>| VoxelClip {
2198 dims,
2199 pivot: [0.0; 3],
2200 voxel_world_size: 1.0,
2201 loop_mode: LoopMode::Loop,
2202 default_frame_ms: 1,
2203 frames,
2204 durations: Vec::new(),
2205 extra_chunks: Vec::new(),
2206 };
2207 assert_eq!(
2208 StreamingClip::new(&mk(Vec::new())).map(|_| ()),
2209 Err(DecodeError::DeltaBeforeKey),
2210 );
2211 assert_eq!(
2212 StreamingClip::new(&mk(vec![EncodedFrame::Delta(Vec::new())])).map(|_| ()),
2213 Err(DecodeError::DeltaBeforeKey),
2214 );
2215 }
2216
2217 #[test]
2220 fn pad_stats_tight_clip_is_not_wasteful() {
2221 let dims = [8u32, 8, 8];
2223 let frame = frame_from_fn(dims, |x, y, z| {
2224 ((x == 0 && y == 0 && z == 0) || (x == 7 && y == 7 && z == 7))
2225 .then_some(VoxColor(0x80FF_FFFF))
2226 });
2227 let s = pad_stats(dims, std::slice::from_ref(&frame));
2228 assert_eq!(s.content_dims, dims);
2229 assert_eq!(s.solid_voxels, 2);
2230 assert!((s.pad_ratio() - 1.0).abs() < 1e-6);
2231 assert!(!s.is_wasteful());
2232 }
2233
2234 #[test]
2235 fn pad_stats_padded_clip_is_wasteful() {
2236 let dims = [40u32, 40, 40];
2238 let frames: Vec<VoxelFrame> = (0..3)
2239 .map(|_| {
2240 frame_from_fn(dims, |x, y, z| {
2241 (x < 10 && y < 10 && z < 10).then_some(VoxColor(0x80FF_0000))
2242 })
2243 })
2244 .collect();
2245 let s = pad_stats(dims, &frames);
2246 assert_eq!(s.content_dims, [10, 10, 10]);
2247 assert_eq!(s.solid_voxels, 3 * 1000);
2248 assert!((s.pad_ratio() - 64.0).abs() < 1e-3);
2250 assert!(s.is_wasteful());
2251 }
2252
2253 #[test]
2254 fn pad_stats_empty_clip_is_not_wasteful() {
2255 let dims = [4u32, 4, 4];
2256 let empty = frame_from_fn(dims, |_, _, _| None);
2257 let s = pad_stats(dims, std::slice::from_ref(&empty));
2258 assert_eq!(s.content_dims, [0, 0, 0]);
2259 assert_eq!(s.solid_voxels, 0);
2260 assert!((s.pad_ratio() - 1.0).abs() < 1e-6);
2261 assert!(!s.is_wasteful());
2262 }
2263
2264 #[test]
2265 fn decoded_clip_pad_stats_delegates() {
2266 let dims = [20u32, 4, 4];
2267 let frame = frame_from_fn(dims, |x, _, _| (x < 4).then_some(VoxColor(0x80FF_FFFF)));
2268 let clip =
2269 VoxelClip::from_frames(dims, [0.0; 3], 1.0, LoopMode::Loop, &[frame], &[], 33, 0);
2270 let s = clip.decode().unwrap().pad_stats();
2271 assert_eq!(s.content_dims, [4, 4, 4]);
2272 assert!((s.pad_ratio() - 5.0).abs() < 1e-3);
2274 assert!(s.is_wasteful());
2275 }
2276
2277 fn is_key(e: &EncodedFrame) -> bool {
2280 matches!(e, EncodedFrame::Key(_))
2281 }
2282 fn key_positions(clip: &VoxelClip) -> Vec<usize> {
2283 clip.frames
2284 .iter()
2285 .enumerate()
2286 .filter_map(|(i, e)| is_key(e).then_some(i))
2287 .collect()
2288 }
2289
2290 #[test]
2291 fn from_frames_auto_round_trips() {
2292 let dims = [4u32, 3, 40];
2293 let frames: Vec<VoxelFrame> = (0..7u32)
2294 .map(|i| {
2295 let h = 5 + i * 5;
2296 frame_from_fn(dims, move |_, _, z| {
2297 (z < h).then_some(VoxColor(0x8000_0000 | (i * 0x10)))
2298 })
2299 })
2300 .collect();
2301 let clip =
2302 VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 0);
2303 assert_eq!(clip.decode().unwrap().frames, frames);
2305 assert!(is_key(&clip.frames[0]), "frame 0 is always a keyframe");
2306 }
2307
2308 #[test]
2309 fn from_frames_auto_keyframes_scene_change_but_deltas_small_change() {
2310 let dims = [4u32, 4, 8];
2311 let a = frame_from_fn(dims, |_, _, z| (z < 4).then_some(VoxColor(0x80FF_0000)));
2312 let scene_cut = frame_from_fn(dims, |_, _, z| (z >= 4).then_some(VoxColor(0x8000_FF00)));
2314 let small = frame_from_fn(dims, |x, y, z| {
2316 ((z < 4) || (x == 0 && y == 0 && z == 4)).then_some(VoxColor(0x80FF_0000))
2317 });
2318
2319 let cut = VoxelClip::from_frames_auto(
2320 dims,
2321 [0.0; 3],
2322 1.0,
2323 LoopMode::Loop,
2324 &[a.clone(), scene_cut],
2325 &[],
2326 33,
2327 0,
2328 );
2329 assert!(is_key(&cut.frames[1]), "scene change → keyframe");
2330
2331 let tweak = VoxelClip::from_frames_auto(
2332 dims,
2333 [0.0; 3],
2334 1.0,
2335 LoopMode::Loop,
2336 &[a, small],
2337 &[],
2338 33,
2339 0,
2340 );
2341 assert!(!is_key(&tweak.frames[1]), "small change → delta");
2342 }
2343
2344 #[test]
2345 fn from_frames_auto_gap_caps_keyframe_spacing() {
2346 let dims = [2u32, 2, 4];
2347 let f = frame_from_fn(dims, |_, _, z| (z < 2).then_some(VoxColor(0x80FF_FFFF)));
2348 let frames = vec![f; 7]; let none =
2352 VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 0);
2353 assert_eq!(key_positions(&none), vec![0]);
2354
2355 let capped =
2357 VoxelClip::from_frames_auto(dims, [0.0; 3], 1.0, LoopMode::Loop, &frames, &[], 33, 3);
2358 assert_eq!(key_positions(&capped), vec![0, 3, 6]);
2359 for (i, e) in capped.frames.iter().enumerate() {
2360 if let EncodedFrame::Delta(d) = e {
2361 assert!(d.is_empty(), "identical frame {i} → empty delta");
2362 }
2363 }
2364 }
2365
2366 #[test]
2367 fn from_kv6_frames_auto_round_trips() {
2368 let dims = [3u32, 3, 6];
2369 let ka = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
2370 (z == 0).then_some(VoxColor(0x80FF_0000))
2371 });
2372 let kb = Kv6::from_fn(dims[0], dims[1], dims[2], |_, _, z| {
2373 (z >= 3).then_some(VoxColor(0x8000_FF00))
2374 });
2375 let clip = VoxelClip::from_kv6_frames_auto(
2376 &[ka.clone(), kb.clone()],
2377 1.0,
2378 LoopMode::Loop,
2379 &[],
2380 33,
2381 0,
2382 )
2383 .expect("import");
2384 let decoded = clip.decode().unwrap();
2385 assert_eq!(decoded.frames[0], VoxelFrame::from_kv6(&ka));
2386 assert_eq!(decoded.frames[1], VoxelFrame::from_kv6(&kb));
2387 }
2388
2389 #[test]
2392 fn from_kv6_does_not_panic_on_malformed_kv6() {
2393 let bad = Kv6 {
2396 xsiz: 2,
2397 ysiz: 2,
2398 zsiz: 4,
2399 xpiv: 0.0,
2400 ypiv: 0.0,
2401 zpiv: 0.0,
2402 voxels: vec![Voxel {
2403 col: 0x80FF_FFFF,
2404 z: 0,
2405 vis: 63,
2406 dir: 0,
2407 }],
2408 xlen: vec![5, 5], ylen: vec![vec![3, 3], vec![3, 3, 99]], palette: None,
2411 };
2412 let f = VoxelFrame::from_kv6(&bad); f.validate([2, 2, 4]).expect("frame is well-formed");
2416 assert_eq!(f.colors, vec![0x80FF_FFFF]);
2417 }
2418
2419 #[test]
2420 fn inflate_rejects_oversized_raw_len() {
2421 let mut bytes = Vec::new();
2424 bytes.extend_from_slice(b"RVCL");
2425 bytes.extend_from_slice(&VERSION.to_le_bytes());
2426 bytes.extend_from_slice(b"META");
2427 bytes.push(CHUNK_FLAG_DEFLATED);
2428 let mut payload = u32::MAX.to_le_bytes().to_vec(); payload.extend_from_slice(&[0x01, 0x00, 0x00]); bytes.extend_from_slice(&(payload.len() as u32).to_le_bytes());
2431 bytes.extend_from_slice(&payload);
2432 assert_eq!(VoxelClip::parse(&bytes), Err(ParseError::BadDeflate));
2433 }
2434
2435 #[test]
2436 fn frame_at_no_overflow_for_huge_durations() {
2437 let durations = vec![u32::MAX / 2, u32::MAX / 2];
2440 let f = frame_at(&durations, LoopMode::PingPong, u32::MAX);
2441 assert!(f < durations.len());
2442 assert!(frame_at(&durations, LoopMode::Loop, u32::MAX) < durations.len());
2444 assert!(frame_at(&durations, LoopMode::Once, u32::MAX) < durations.len());
2445 }
2446
2447 #[test]
2452 fn frame_at_prefix_matches_linear_walk() {
2453 let timelines: [&[u32]; 7] = [
2454 &[],
2455 &[100],
2456 &[100, 100, 100],
2457 &[50, 0, 50, 200],
2458 &[0, 0, 0],
2459 &[1, 1, 1, 1, 1],
2460 &[u32::MAX / 2, u32::MAX / 2],
2461 ];
2462 let modes = [LoopMode::Loop, LoopMode::Once, LoopMode::PingPong];
2463 for durations in timelines {
2464 let prefix = duration_prefix_sums(durations);
2465 for mode in modes {
2466 for t in (0u32..700).chain([u32::MAX - 1, u32::MAX]) {
2467 assert_eq!(
2468 frame_at_prefix(&prefix, mode, t),
2469 frame_at(durations, mode, t),
2470 "durations={durations:?} mode={mode:?} t={t}"
2471 );
2472 }
2473 }
2474 }
2475 }
2476}