Skip to main content

ranim_core/animation/
mod.rs

1//! Pure animation evaluation and hierarchical type-erased composition.
2//!
3//! The runtime tree is a closed world of node content ([`NodeContent`](crate::animation::node::NodeContent)): the
4//! time-combinator vocabulary (sequence, stack) plus the leaf forms. This
5//! module is split by layer:
6//!
7//! - [`node`] — the closed runtime core and all structural interpreters;
8//! - [`eval`] — the open, typed visual-leaf protocol;
9//! - [`build`] — the lowering protocol from authoring definitions to
10//!   [`AnimNode`](crate::animation::node::AnimNode);
11//! - [`compose`] — authoring sugar (`AnimSequence`, `AnimStack`, `AnimLagged`)
12//!   that lowers to the core vocabulary.
13//!
14//! Authoring is open above the core: user leaves enter through
15//! [`Eval`](crate::animation::eval::Eval), and combinators expressible as
16//! placements (e.g. [`AnimLagged`](crate::animation::compose::lagged::AnimLagged))
17//! desugar into the primitives in
18//! [`IntoAnimNode`](crate::animation::build::IntoAnimNode).
19
20/// Authoring protocol and playback wrappers.
21pub mod build;
22/// Built-in composition containers and iterator helpers.
23pub mod compose;
24/// Evaluation protocols and author-facing adapters.
25pub mod eval;
26/// Runtime animation nodes and their interpreters.
27pub mod node;
28/// Audio leaf animation: a sound placed and composed like any animation.
29pub mod sound;
30
31#[cfg(test)]
32mod tests {
33    use super::{
34        build::{IntoAnimNode, PlaybackExt, Unplaced},
35        compose::{AnimIterExt, lagged::LaggedFill, sequence::AnimSequence, stack::AnimStack},
36        eval::{Eval, EvalExt, Static, StaticAnim, pure::Pure},
37        node::{AnimNode, AnimationInfoKind, NodeContent},
38    };
39    use crate::{
40        Extract,
41        core_item::{CoreItem, DynItem, vitem::VItem},
42        lagged, seq, stack,
43    };
44
45    /// A stateless test double: a `VItem` shifted to a fixed x.
46    struct ShiftX(f32);
47
48    impl Eval for ShiftX {
49        type Output = VItem;
50
51        fn eval_alpha(&self, _alpha: f64) -> Self::Output {
52            let mut item = VItem::default();
53            item.points[0].x = self.0;
54            item
55        }
56    }
57
58    /// A stateless test double: x = offset + alpha.
59    struct ShiftAlpha(f32);
60
61    impl Eval for ShiftAlpha {
62        type Output = VItem;
63
64        fn eval_alpha(&self, alpha: f64) -> Self::Output {
65            let mut item = VItem::default();
66            item.points[0].x = self.0 + alpha as f32;
67            item
68        }
69    }
70
71    fn leaf(x: f32, duration: f64) -> impl Unplaced {
72        ShiftX(x).with_duration(duration)
73    }
74
75    fn progress_leaf(offset: f32) -> impl Unplaced {
76        ShiftAlpha(offset)
77    }
78
79    fn evaluated_xs(items: Vec<DynItem>) -> Vec<f32> {
80        items
81            .into_iter()
82            .flat_map(|item| item.extract())
83            .filter_map(|item| match item {
84                CoreItem::VItem(item) => Some(item.points[0].x),
85                CoreItem::CameraFrame(_) | CoreItem::MeshItem(_) => None,
86            })
87            .collect()
88    }
89
90    fn sampled_xs(animation: &AnimNode, sec: f64) -> Vec<f32> {
91        let mut items = Vec::new();
92        animation.eval_at(sec, &mut items);
93        evaluated_xs(items)
94    }
95
96    #[test]
97    fn apply_alpha_to_writes_the_requested_progress_state() {
98        let mut item = VItem::default();
99
100        let animation = ShiftAlpha(0.0).apply_alpha_to(&mut item, 0.25);
101        assert_eq!(item.points[0].x, 0.25);
102
103        let animation = animation.apply_to(&mut item);
104        assert_eq!(item.points[0].x, 1.0);
105        assert_eq!(animation.eval_alpha(0.5).points[0].x, 0.5);
106    }
107
108    #[test]
109    fn pure_wraps_a_closure_into_eval() {
110        let mut item = VItem::default();
111        Pure::new(|alpha: f64| {
112            let mut item = VItem::default();
113            item.points[0].x = alpha as f32;
114            item
115        })
116        .apply_alpha_to(&mut item, 0.5);
117        assert_eq!(item.points[0].x, 0.5);
118    }
119
120    #[test]
121    fn containers_lower_children_to_expected_timelines() {
122        // Default lowering: linear rate, one second.
123        let animation = Static(VItem::default()).into_anim_node();
124        assert_eq!(animation.time_range(), 0.0..1.0);
125        let animation = Static(VItem::default()).with_duration(2.0).into_anim_node();
126        assert_eq!(animation.time_range(), 0.0..2.0);
127
128        // Sequence concatenates child durations; stack keeps them at the
129        // same origin, honouring explicit child positions.
130        let sequence = seq![leaf(1.0, 2.0), leaf(2.0, 3.0)];
131        assert_eq!(sequence.built_animations()[0].time_range(), 0.0..2.0);
132        assert_eq!(sequence.built_animations()[1].time_range(), 2.0..5.0);
133        let stack = stack![leaf(1.0, 2.0), leaf(2.0, 3.0).at(1.0)];
134        assert_eq!(stack.duration_secs(), 4.0);
135        assert_eq!(stack.built_animations()[0].time_range(), 0.0..2.0);
136        assert_eq!(stack.built_animations()[1].time_range(), 1.0..4.0);
137
138        let mut dynamic = AnimStack::new();
139        dynamic.push(leaf(1.0, 1.0)).push(leaf(2.0, 3.0));
140        assert_eq!(dynamic.duration_secs(), 3.0);
141        assert_eq!(dynamic.built_animations()[0].time_range(), 0.0..1.0);
142        assert_eq!(dynamic.built_animations()[1].time_range(), 0.0..3.0);
143
144        // Collectors build the same containers.
145        let stack: AnimStack = vec![leaf(1.0, 1.0), leaf(2.0, 2.0)].into_iter().collect();
146        assert_eq!(stack.duration_secs(), 2.0);
147        assert_eq!(stack.built_animations()[1].time_range(), 0.0..2.0);
148        let sequence: AnimSequence = vec![leaf(1.0, 1.0), leaf(2.0, 2.0)].into_iter().collect();
149        assert_eq!(sequence.duration_secs(), 3.0);
150        assert_eq!(sequence.built_animations()[1].time_range(), 1.0..3.0);
151        let lagged = vec![leaf(1.0, 1.0), leaf(2.0, 1.0)]
152            .into_iter()
153            .into_lagged(0.5);
154        assert_eq!(lagged.built_animations()[1].time_range(), 0.5..1.5);
155    }
156
157    #[test]
158    fn offsets_shift_the_whole_container_timeline() {
159        let animation = leaf(1.0, 2.0).at(3.0).into_anim_node();
160        assert_eq!(animation.time_range(), 3.0..5.0);
161
162        let sequence = seq![leaf(1.0, 2.0), leaf(2.0, 3.0)];
163        assert_eq!(sequence.at(5.0).into_anim_node().time_range(), 5.0..10.0);
164
165        let stack = stack![leaf(1.0, 2.0), leaf(2.0, 3.0).at(1.0)];
166        assert_eq!(stack.at(10.0).into_anim_node().time_range(), 10.0..14.0);
167
168        // An extended sequence keeps its local gaps after being repositioned.
169        let mut sequence = AnimSequence::new();
170        sequence
171            .push(leaf(1.0, 2.0))
172            .forward(1.0)
173            .push(leaf(2.0, 1.0));
174        let info = sequence.at(10.0).into_anim_node().animation_info();
175        assert_eq!(info.range, 10.0..14.0);
176        assert_eq!(info.children[0].range, 0.0..2.0);
177        assert_eq!(info.children[1].range, 3.0..4.0);
178    }
179
180    #[test]
181    fn parametrized_sequence_remaps_the_group_timeline() {
182        use crate::utils::rate_functions::ease_in_quad;
183
184        let animation = seq![progress_leaf(0.0), progress_leaf(10.0)]
185            .with_duration(4.0)
186            .with_rate_func(ease_in_quad);
187        let animation = animation.into_anim_node();
188
189        assert_eq!(animation.time_range(), 0.0..4.0);
190        assert_eq!(sampled_xs(&animation, 2.0), vec![0.5]);
191        let info = animation.animation_info();
192        assert_eq!(info.range, 0.0..4.0);
193        assert_eq!(info.content_duration_secs, 2.0);
194        assert_eq!(info.children.len(), 2);
195        assert_eq!(info.children[0].range, 0.0..1.0);
196        assert_eq!(info.children[1].range, 1.0..2.0);
197    }
198
199    #[test]
200    fn extend_appends_direct_children_and_preserves_local_gaps() {
201        let mut source = AnimSequence::new();
202        source
203            .push(leaf(2.0, 1.0))
204            .forward(2.0)
205            .push(leaf(3.0, 1.0));
206
207        let mut sequence = AnimSequence::new();
208        sequence.push(leaf(1.0, 2.0)).extend(source);
209        assert_eq!(sequence.cursor_sec(), 6.0);
210        assert_eq!(sequence.built_animations().len(), 3);
211        assert_eq!(sequence.built_animations()[0].time_range(), 0.0..2.0);
212        assert_eq!(sequence.built_animations()[1].time_range(), 2.0..3.0);
213        assert_eq!(sequence.built_animations()[2].time_range(), 5.0..6.0);
214
215        let source = stack![leaf(2.0, 1.0), leaf(3.0, 2.0).at(1.0)];
216        let mut stack = stack![leaf(1.0, 4.0)];
217        stack.extend(source);
218        assert_eq!(stack.duration_secs(), 4.0);
219        assert_eq!(stack.built_animations().len(), 3);
220        assert_eq!(stack.built_animations()[1].time_range(), 0.0..1.0);
221        assert_eq!(stack.built_animations()[2].time_range(), 1.0..3.0);
222    }
223
224    #[test]
225    fn composition_macros_build_dynamic_containers_without_an_arity_limit() {
226        let empty_sequence: AnimSequence = seq![];
227        let empty_stack: AnimStack = stack![];
228        assert_eq!(empty_sequence.duration_secs(), 0.0);
229        assert_eq!(empty_stack.duration_secs(), 0.0);
230
231        let sequence: AnimSequence = seq![
232            leaf(1.0, 1.0),
233            leaf(2.0, 1.0),
234            leaf(3.0, 1.0),
235            leaf(4.0, 1.0),
236            leaf(5.0, 1.0),
237            leaf(6.0, 1.0),
238            leaf(7.0, 1.0),
239            leaf(8.0, 1.0),
240            leaf(9.0, 1.0),
241        ];
242        assert_eq!(sequence.duration_secs(), 9.0);
243        assert_eq!(sequence.built_animations().len(), 9);
244
245        let stack: AnimStack = stack![
246            leaf(1.0, 1.0),
247            leaf(2.0, 2.0),
248            leaf(3.0, 3.0),
249            leaf(4.0, 4.0),
250            leaf(5.0, 5.0),
251            leaf(6.0, 6.0),
252            leaf(7.0, 7.0),
253            leaf(8.0, 8.0),
254            leaf(9.0, 9.0),
255        ];
256        assert_eq!(stack.duration_secs(), 9.0);
257        assert_eq!(stack.built_animations().len(), 9);
258    }
259
260    #[test]
261    fn holds_sample_active_items_and_repeat_without_nesting() {
262        // A hold samples only animations active before the cursor.
263        let mut sequence = AnimSequence::new();
264        sequence
265            .push(stack![leaf(1.0, 1.0), leaf(2.0, 2.0)])
266            .hold(1.0);
267        assert_eq!(sequence.built_animations().len(), 2);
268        assert_eq!(sampled_xs(&sequence.built_animations()[1], 2.5), vec![2.0]);
269
270        // Repeated holds become adjacent static cells.
271        let mut sequence = AnimSequence::new();
272        sequence.push(leaf(3.0, 1.0)).hold(1.0).hold(2.0);
273        assert_eq!(sequence.cursor_sec(), 4.0);
274        assert_eq!(sequence.built_animations().len(), 3);
275        assert_eq!(sequence.built_animations()[1].time_range(), 1.0..2.0);
276        assert_eq!(sequence.built_animations()[2].time_range(), 2.0..4.0);
277        assert_eq!(sampled_xs(&sequence.built_animations()[2], 3.5), vec![3.0]);
278
279        // Every replay flattens the dyn batch instead of nesting it.
280        let mut sequence = AnimSequence::new();
281        sequence
282            .push(stack![leaf(1.0, 1.0), leaf(2.0, 1.0)])
283            .hold(1.0)
284            .hold(1.0);
285        let mut first = Vec::new();
286        sequence.built_animations()[1].eval_at(1.5, &mut first);
287        let mut second = Vec::new();
288        sequence.built_animations()[2].eval_at(2.5, &mut second);
289        assert_eq!(first.len(), 2);
290        assert_eq!(second.len(), 2);
291        assert_eq!(evaluated_xs(second), vec![1.0, 2.0]);
292    }
293
294    #[test]
295    fn holds_use_final_evaluations_and_forward_does_not_hold() {
296        let mut shown = VItem::default();
297        shown.points[0].x = 5.0;
298
299        let mut hidden = AnimSequence::new();
300        hidden.push(leaf(1.0, 1.0)).push(shown.hide()).hold(1.0);
301        assert_eq!(hidden.built_animations().len(), 2);
302        let mut hidden_items = Vec::new();
303        hidden.built_animations()[1].eval_at(1.0, &mut hidden_items);
304        assert!(hidden_items.is_empty());
305
306        let mut restored = AnimSequence::new();
307        restored.push(leaf(1.0, 1.0)).push(shown.show()).hold(1.0);
308        assert_eq!(sampled_xs(&restored.built_animations()[2], 1.5), vec![5.0]);
309
310        // `forward` only advances the cursor; it does not emit a hold cell.
311        let mut sequence = AnimSequence::new();
312        sequence.push(leaf(4.0, 1.0)).forward(1.0).hold(1.0);
313        assert_eq!(sequence.cursor_sec(), 3.0);
314        assert_eq!(sequence.built_animations().len(), 1);
315
316        // Nested sequences keep their own final evaluation.
317        let mut shown = VItem::default();
318        shown.points[0].x = 7.0;
319        let inner = seq![leaf(1.0, 1.0), shown.show()];
320        let mut outer = AnimSequence::new();
321        outer.push(inner).hold(1.0);
322        assert_eq!(outer.built_animations().len(), 2);
323        assert_eq!(sampled_xs(&outer.built_animations()[1], 1.5), vec![7.0]);
324
325        let hidden_inner = seq![leaf(1.0, 1.0), shown.hide()];
326        let mut hidden_outer = AnimSequence::new();
327        hidden_outer.push(hidden_inner).hold(1.0);
328        assert_eq!(hidden_outer.built_animations().len(), 1);
329    }
330
331    #[test]
332    fn lagged_staggers_and_fills_window_edges() {
333        // Stagger by a ratio of each previous child's duration.
334        let lagged = lagged![0.5; leaf(1.0, 1.0), leaf(2.0, 2.0), leaf(3.0, 1.0)];
335        assert_eq!(lagged.built_animations()[0].time_range(), 0.0..1.0);
336        assert_eq!(lagged.built_animations()[1].time_range(), 0.5..2.5);
337        assert_eq!(lagged.built_animations()[2].time_range(), 1.5..2.5);
338        assert_eq!(lagged.duration_secs(), 2.5);
339
340        // ratio 1.0 is a sequence, ratio 0.0 is a stack.
341        let as_sequence = lagged![1.0; leaf(1.0, 1.0), leaf(2.0, 1.0)];
342        assert_eq!(as_sequence.built_animations()[1].time_range(), 1.0..2.0);
343        let as_stack = lagged![0.0; leaf(1.0, 1.0), leaf(2.0, 2.0)];
344        assert_eq!(as_stack.built_animations()[1].time_range(), 0.0..2.0);
345        assert_eq!(as_stack.duration_secs(), 2.0);
346
347        // Default leading/trailing fills hold the edge states.
348        let animation = lagged![
349            0.5;
350            progress_leaf(0.0).with_duration(1.0),
351            progress_leaf(10.0).with_duration(1.0)
352        ]
353        .into_anim_node();
354        assert_eq!(animation.time_range(), 0.0..1.5);
355        assert_eq!(sampled_xs(&animation, 0.25), vec![0.25, 10.0]);
356        assert_eq!(sampled_xs(&animation, 1.0), vec![1.0, 10.5]);
357        assert_eq!(sampled_xs(&animation, 1.5), vec![1.0, 11.0]);
358
359        // `with_leading(Empty)` leaves the pre-window empty, trailing holds.
360        let empty_leading = lagged![
361            0.5;
362            progress_leaf(0.0).with_duration(1.0),
363            progress_leaf(10.0).with_duration(1.0)
364        ]
365        .with_leading(LaggedFill::Empty)
366        .into_anim_node();
367        assert_eq!(sampled_xs(&empty_leading, 0.25), vec![0.25]);
368        assert_eq!(sampled_xs(&empty_leading, 1.0), vec![1.0, 10.5]);
369
370        // A child ending with `hide` stays hidden after its own window.
371        let mut shown = VItem::default();
372        shown.points[0].x = 2.0;
373        let hidden = lagged![0.5; seq![leaf(2.0, 1.0), shown.hide()]].into_anim_node();
374        assert_eq!(sampled_xs(&hidden, 0.5), vec![2.0]);
375        let mut items = Vec::new();
376        hidden.eval_at(1.0, &mut items);
377        assert!(items.is_empty());
378    }
379
380    #[test]
381    fn lagged_fill_structure_is_materialized_as_per_item_tracks() {
382        let animation = lagged![
383            0.5;
384            progress_leaf(0.0).with_duration(1.0),
385            progress_leaf(10.0).with_duration(1.0),
386        ]
387        .into_anim_node();
388        let info = animation.animation_info();
389        // Each item becomes a sequence track spanning the whole extent:
390        // [leading fill][anim][trailing fill] (empty fills skipped).
391        assert_eq!(info.children.len(), 2);
392
393        let first = &info.children[0];
394        assert_eq!(first.kind, AnimationInfoKind::Sequence);
395        assert_eq!(first.range, 0.0..1.5);
396        assert_eq!(first.children.len(), 2);
397        assert_eq!(first.children[0].kind, AnimationInfoKind::Eval);
398        assert_eq!(first.children[0].range, 0.0..1.0);
399        assert_eq!(first.children[1].kind, AnimationInfoKind::Static);
400        assert_eq!(first.children[1].range, 1.0..1.5);
401
402        let second = &info.children[1];
403        assert_eq!(second.kind, AnimationInfoKind::Sequence);
404        assert_eq!(second.range, 0.0..1.5);
405        assert_eq!(second.children.len(), 2);
406        assert_eq!(second.children[0].kind, AnimationInfoKind::Static);
407        assert_eq!(second.children[0].range, 0.0..0.5);
408        assert_eq!(second.children[1].kind, AnimationInfoKind::Eval);
409        assert_eq!(second.children[1].range, 0.5..1.5);
410    }
411
412    // MARK: Sound leaves in the tree
413
414    use super::sound::Sound;
415    use crate::RanimScene;
416    use crate::audio::{AudioClip, MASTER_SAMPLE_RATE};
417    use crate::utils::rate_functions::ease_in_quad;
418
419    fn tone(secs: f64) -> AudioClip {
420        // A constant-amplitude clip: probes never land on a zero crossing.
421        let pcm = vec![0.5f32; (secs * 48_000.0) as usize];
422        AudioClip::from_pcm(pcm, 48_000, 1)
423    }
424
425    /// Mix the scene's audio and report (total, first, last) in seconds —
426    /// total scene length and the first/last sample-seconds with audible
427    /// energy.
428    fn audible_region(scene: RanimScene) -> (f64, f64, f64) {
429        let sealed = scene.seal();
430        let total = sealed.total_secs();
431        let evaluator = sealed.into_evaluator(120.0);
432        let buf = evaluator.mix_audio(total, MASTER_SAMPLE_RATE);
433        let first = buf
434            .iter()
435            .position(|s| s.abs() > 1e-4)
436            .expect("expected audible samples");
437        let last = buf.len()
438            - 1
439            - buf
440                .iter()
441                .rev()
442                .position(|s| s.abs() > 1e-4)
443                .expect("non-empty");
444        (
445            total,
446            first as f64 / 2.0 / MASTER_SAMPLE_RATE as f64,
447            last as f64 / 2.0 / MASTER_SAMPLE_RATE as f64,
448        )
449    }
450
451    #[test]
452    fn sound_windows_follow_placement() {
453        // Sequential placement occupies exactly the sound's own window.
454        let mut scene = RanimScene::new();
455        scene.play(seq![leaf(1.0, 1.0), Sound::new(tone(2.0))]);
456        let (total, start, end) = audible_region(scene);
457        assert!((total - 3.0).abs() < 1e-9);
458        assert!((start - 1.0).abs() < 0.01);
459        assert!((end - 3.0).abs() < 0.01);
460
461        // `.at()` shifts that window on the timeline.
462        let mut scene = RanimScene::new();
463        scene.play(stack![Sound::new(tone(1.0)).at(2.0)]);
464        let (_, start, end) = audible_region(scene);
465        assert!((start - 2.0).abs() < 0.01);
466        assert!((end - 3.0).abs() < 0.01);
467    }
468
469    #[test]
470    fn sound_frames_push_no_items() {
471        let mut scene = RanimScene::new();
472        scene.play(Sound::new(tone(2.0)));
473        let sealed = scene.seal();
474
475        assert_eq!(sealed.eval_at_sec(1.0).count(), 0);
476    }
477
478    #[test]
479    fn disabled_sound_is_excluded() {
480        let mut scene = RanimScene::new();
481        scene.play(stack![Sound::new(tone(1.0)).with_enabled(false)]);
482        let evaluator = scene.seal().into_evaluator(120.0);
483        assert!(evaluator.has_audio());
484        assert!(
485            evaluator
486                .mix_audio(1.0, MASTER_SAMPLE_RATE)
487                .iter()
488                .all(|s| s.abs() < 1e-4)
489        );
490    }
491
492    #[test]
493    fn scene_without_sound_has_no_audio() {
494        let mut scene = RanimScene::new();
495        scene.play(leaf(1.0, 1.0));
496        assert!(!scene.seal().into_evaluator(120.0).has_audio());
497    }
498
499    #[test]
500    fn container_duration_override_rescales_sound() {
501        // The inner sequence's 2s of content (the sound itself) is squeezed
502        // into a 1s window: the clip is consumed twice as fast and stays
503        // audible exactly for the squeezed span.
504        let inner = seq![Sound::new(tone(2.0))];
505        let mut scene = RanimScene::new();
506        scene.play(inner.with_duration(1.0));
507        let (_, start, end) = audible_region(scene);
508        assert!((start - 0.0).abs() < 0.01);
509        assert!((end - 1.0).abs() < 0.01);
510    }
511
512    #[test]
513    fn container_rate_func_warps_the_sound_window() {
514        // ease_in_quad maps scene progress u to content u²; the sound occupies
515        // content [1, 2] of 2, so it becomes audible when u² >= 1/2, at scene
516        // time 2·sqrt(1/2) ≈ 1.4142.
517        let inner = seq![leaf(1.0, 1.0), Sound::new(tone(1.0))];
518        let mut scene = RanimScene::new();
519        scene.play(inner.with_rate_func(ease_in_quad));
520
521        let (_, start, end) = audible_region(scene);
522        let expected_start = 2.0 * (0.5f64).sqrt();
523        assert!((start - expected_start).abs() < 0.01, "start {start}");
524        assert!((end - 2.0).abs() < 0.01, "end {end}");
525    }
526
527    #[test]
528    fn sound_rate_func_warps_its_content_progress() {
529        // A linear ramp clip under ease_in_quad: scene time t reads clip
530        // position t², so amplitude at t=0.5 is 0.25 (not 0.5).
531        let clip = AudioClip::from_pcm(
532            (0..48_000).map(|i| i as f32 / 48_000.0).collect::<Vec<_>>(),
533            48_000,
534            1,
535        );
536        let mut scene = RanimScene::new();
537        scene.play(Sound::new(clip).with_rate_func(ease_in_quad));
538        let evaluator = scene.seal().into_evaluator(120.0);
539        let buf = evaluator.mix_audio(1.0, 48_000);
540        // Stereo-interleaved: the L sample of frame (0.5 s × 48 kHz).
541        let mid = buf[(0.5 * 48_000.0) as usize * 2];
542        assert!((mid - 0.25).abs() < 0.01, "amplitude at 0.5s was {mid}");
543    }
544
545    #[test]
546    fn warped_container_of_warped_sounds_mixes_all_of_them() {
547        // Non-linear rates everywhere: nothing is pre-bakeable, so the whole
548        // stack must resolve through the per-sample walk — every leaf, no
549        // marking. 10 overlapping constant tones of 0.25 must sum to ~2.5.
550        let clip = AudioClip::from_pcm(vec![0.25f32; 48_000], 48_000, 1);
551        let mut stack = AnimStack::new();
552        for _ in 0..10 {
553            stack.push(Sound::new(clip.clone()).with_rate_func(ease_in_quad));
554        }
555        let mut scene = RanimScene::new();
556        scene.play(stack.with_rate_func(ease_in_quad));
557        let buf = scene.seal().into_evaluator(120.0).mix_audio(0.5, 48_000);
558        for frame in [0usize, 12_000, 23_999] {
559            assert!(
560                (buf[frame * 2] - 2.5).abs() < 1e-3,
561                "frame {frame}: {} (expected 10 × 0.25)",
562                buf[frame * 2]
563            );
564        }
565    }
566
567    #[test]
568    fn nested_containers_compose_the_sound_window() {
569        let inner = seq![leaf(1.0, 1.0), Sound::new(tone(1.0))];
570        let mut scene = RanimScene::new();
571        scene.play(seq![inner, Sound::new(tone(0.5))]);
572        let evaluator = scene.seal().into_evaluator(120.0);
573        let buf = evaluator.mix_audio(evaluator.total_secs(), MASTER_SAMPLE_RATE);
574        let at = |sec: f64| buf[(sec * MASTER_SAMPLE_RATE as f64) as usize * 2];
575        // The first sound plays over [1, 2] (inside the inner sequence), the
576        // second over [2, 2.5] (after it in the outer sequence).
577        assert!(at(0.5).abs() < 1e-4);
578        assert!(at(1.5).abs() > 1e-4);
579        assert!(at(2.25).abs() > 1e-4);
580    }
581
582    #[test]
583    fn sound_with_a_tail_extends_the_scene_to_its_window() {
584        // Placement semantics are uniform: a sound's window occupies
585        // timeline space just like a visual's. Clip sample counts are
586        // quantized, so an author synthesizing to a target duration should
587        // floor (not ceil) the sample count to avoid a sub-frame tail.
588        let clip_len = 48_001; // 1.0000208..s at 48 kHz
589        let clip = AudioClip::from_pcm(vec![0.5f32; clip_len], 48_000, 1);
590        let mut scene = RanimScene::new();
591        scene.play(stack![
592            Static(VItem::default()).with_duration(1.0),
593            Sound::new(clip)
594        ]);
595        assert!((scene.seal().total_secs() - clip_len as f64 / 48_000.0).abs() < 1e-9);
596    }
597
598    #[test]
599    fn linear_rate_is_structural() {
600        // A default-built cell must carry the structural linear rate
601        // (`None`), never an identity fn pointer — function pointer
602        // addresses are not guaranteed unique, so linearity cannot be
603        // detected by comparison. Future mixing fast paths compose affine
604        // maps only while every rate along the path is linear.
605        let cells = [
606            AnimSequence::new().into_anim_node(),
607            AnimStack::new().into_anim_node(),
608            Sound::new(tone(0.01)).into_anim_node(),
609            leaf(1.0, 1.0).into_anim_node(),
610        ];
611        for cell in &cells {
612            assert!(
613                cell.rate_func.is_none(),
614                "a default-built cell must carry the structural linear rate"
615            );
616        }
617        let warped = leaf(1.0, 1.0).with_rate_func(ease_in_quad).into_anim_node();
618        assert!(warped.rate_func.is_some());
619    }
620
621    #[test]
622    fn baked_audio_matches_point_semantics() {
623        // One scene exercising every mixing shape: sequential and
624        // overlapping sounds, duration overrides (one acting as a linear
625        // speed change on a sound), container and leaf rate warps, fades,
626        // gain, a stereo clip, and visual filler cells. The seal-time bake
627        // must equal a fresh per-sample walk of the same tree (the
628        // point-semantics spec).
629        let ramp = |i: usize| 0.4 * i as f32 / 48_000.0;
630        let stereo = AudioClip::from_pcm(
631            (0..48_000)
632                .flat_map(|i| [ramp(i), ramp(i)])
633                .collect::<Vec<_>>(),
634            48_000,
635            2,
636        );
637        let warped = seq![
638            Static(VItem::default()).with_duration(1.0),
639            Sound::new(tone(1.0))
640        ]
641        .with_rate_func(ease_in_quad);
642
643        let mut scene = RanimScene::new();
644        scene.play(stack![
645            seq![
646                Sound::new(AudioClip::sine(440.0, 1.0, 0.5))
647                    .with_fade_in(0.25)
648                    .with_gain(0.8),
649                // A 2x speed change: half the window, pitch up an octave.
650                Sound::new(stereo).with_duration(0.5),
651            ],
652            Sound::new(AudioClip::sine(880.0, 2.0, 0.3)).at(0.5),
653            seq![Sound::new(tone(2.0))].with_duration(1.7),
654            warped,
655            Sound::new(tone(1.0)).with_rate_func(ease_in_quad),
656            Static(VItem::default()).with_duration(4.0),
657        ]);
658        let sealed = scene.seal();
659        let baked = sealed.audio().clone();
660        let evaluator = sealed.into_evaluator(120.0);
661        assert_eq!(baked, evaluator.audio().clone());
662
663        fn cell_at(cell: &AnimNode, x: f64) -> [f32; 2] {
664            if !cell.enabled || x < cell.time_range.start || x >= cell.time_range.end {
665                return [0.0; 2];
666            }
667            let raw = (x - cell.time_range.start) / cell.duration_secs();
668            let own = cell.internal_time_secs * cell.rate_func.map_or(raw, |rate| rate(raw));
669            match &cell.content {
670                NodeContent::Audio(track) => track.sample_at(own),
671                _ => {
672                    let mut acc = [0.0f32; 2];
673                    for child in cell.children() {
674                        let [l, r] = cell_at(child, own);
675                        acc[0] += l;
676                        acc[1] += r;
677                    }
678                    acc
679                }
680            }
681        }
682
683        let frames = baked.len() / 2;
684        for frame in 0..frames {
685            let x = frame as f64 / MASTER_SAMPLE_RATE as f64;
686            let mut acc = [0.0f32; 2];
687            for cell in evaluator.cells() {
688                let [l, r] = cell_at(cell, x);
689                acc[0] += l;
690                acc[1] += r;
691            }
692            assert!(
693                (baked[frame * 2] - acc[0]).abs() < 1e-6
694                    && (baked[frame * 2 + 1] - acc[1]).abs() < 1e-6,
695                "frame {frame}: baked [{}, {}] vs walk {acc:?}",
696                baked[frame * 2],
697                baked[frame * 2 + 1]
698            );
699        }
700    }
701}