Skip to main content

rpptx_layout/
timeline.rs

1//! Deterministic evaluation of PresentationML timing trees.
2
3use std::collections::HashMap;
4
5use oxml_layout::{Diagnostic, Path, PathCommand, Point, Rect, Transform};
6use rpptx_oxml::timing::{
7    CT_SlideTransition, CT_Timing, CommonTimeNode, MediaCommandKind, TimingAnimate,
8    TimingCondition, TimingContainer, TimingDuration, TimingEffect, TimingEvent, TimingFill,
9    TimingMotionPath, TimingNode, TimingNodeType, TimingSequence, TimingSet, TimingTarget,
10    TransitionEffect, TransitionParameter, TransitionSpeed,
11};
12
13use crate::{FlattenedSource, ResolveError, ResolvedSlide};
14
15/// Explicit slide-local timeline input supplied by a caller.
16#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
17pub struct TimelinePosition {
18    pub elapsed_ms: u64,
19    pub click_count: u32,
20}
21
22/// Evaluated playback phase for one media shape.
23#[derive(Clone, Copy, Debug, Eq, PartialEq)]
24pub enum MediaPlaybackPhase {
25    Stopped,
26    Playing,
27    Paused,
28}
29
30/// Evaluated playback state for one stable media shape id.
31#[derive(Clone, Debug, PartialEq)]
32pub struct EvaluatedMediaState {
33    pub shape_id: u32,
34    pub phase: MediaPlaybackPhase,
35    pub source_position_ms: u64,
36    pub volume: f32,
37    pub looping: bool,
38}
39
40/// Evaluated renderer state for one shape target.
41#[derive(Clone, Debug, PartialEq)]
42pub struct EvaluatedShapeState {
43    pub visible: bool,
44    pub opacity: f32,
45    pub transform: Transform,
46    /// Optional shape-local normalized reveal rectangle.
47    pub clip: Option<Rect>,
48    animation: EvaluatedAnimationTransform,
49    oriented_clips: Vec<[Point; 4]>,
50}
51
52impl Default for EvaluatedShapeState {
53    fn default() -> Self {
54        Self {
55            visible: true,
56            opacity: 1.0,
57            transform: Transform::IDENTITY,
58            clip: None,
59            animation: EvaluatedAnimationTransform::default(),
60            oriented_clips: Vec::new(),
61        }
62    }
63}
64
65#[derive(Clone, Copy, Debug, PartialEq)]
66struct EvaluatedAnimationTransform {
67    scale_x: f64,
68    scale_y: f64,
69    rotation_deg: f64,
70    motion: Option<EvaluatedMotion>,
71}
72
73impl Default for EvaluatedAnimationTransform {
74    fn default() -> Self {
75        Self {
76            scale_x: 1.0,
77            scale_y: 1.0,
78            rotation_deg: 0.0,
79            motion: None,
80        }
81    }
82}
83
84#[derive(Clone, Copy, Debug, PartialEq)]
85struct EvaluatedMotion {
86    x: f64,
87    y: f64,
88    origin: MotionOrigin,
89}
90
91#[derive(Clone, Copy, Debug, PartialEq)]
92enum MotionOrigin {
93    Parent,
94    Layout,
95}
96
97impl EvaluatedShapeState {
98    pub(crate) fn push_oriented_clip(&mut self, points: [Point; 4]) {
99        self.oriented_clips.push(points);
100    }
101
102    /// Resolve timeline reveal clips into shape-local point geometry.
103    pub fn local_clip_paths(&self, size: (f64, f64)) -> Vec<Path> {
104        let scale = |point: Point| Point {
105            x: point.x * size.0,
106            y: point.y * size.1,
107        };
108        let mut paths = Vec::new();
109        if let Some(clip) = self.clip {
110            paths.push(Path::rect(Rect {
111                x: clip.x * size.0,
112                y: clip.y * size.1,
113                width: clip.width * size.0,
114                height: clip.height * size.1,
115            }));
116        }
117        for points in &self.oriented_clips {
118            paths.push(Path {
119                commands: vec![
120                    PathCommand::MoveTo(scale(points[0])),
121                    PathCommand::LineTo(scale(points[1])),
122                    PathCommand::LineTo(scale(points[2])),
123                    PathCommand::LineTo(scale(points[3])),
124                    PathCommand::Close,
125                ],
126                fill_rule: oxml_layout::FillRule::NonZero,
127            });
128        }
129        paths
130    }
131
132    fn rebuild_public_transform(&mut self) {
133        let scale = scale_about(self.animation.scale_x, self.animation.scale_y, 0.5, 0.5);
134        let rotation = Transform::rotate_about(self.animation.rotation_deg, 0.5, 0.5);
135        let motion = self.animation.motion.map_or(Transform::IDENTITY, |motion| {
136            translation(motion.x, motion.y)
137        });
138        self.transform = scale.then(rotation).then(motion);
139    }
140
141    pub(crate) fn resolved_shape_geometry(&self) -> (f64, f64, f64) {
142        (
143            self.animation.scale_x,
144            self.animation.scale_y,
145            self.animation.rotation_deg,
146        )
147    }
148
149    pub(crate) fn resolved_page_transform(
150        &self,
151        center: (f64, f64),
152        slide_size: (f64, f64),
153    ) -> Transform {
154        let scale = scale_about(
155            self.animation.scale_x,
156            self.animation.scale_y,
157            center.0,
158            center.1,
159        );
160        let rotation = Transform::rotate_about(self.animation.rotation_deg, center.0, center.1);
161        let motion = self.resolved_motion_translation(center, slide_size);
162        scale.then(rotation).then(motion)
163    }
164
165    pub(crate) fn resolved_motion_translation(
166        &self,
167        center: (f64, f64),
168        slide_size: (f64, f64),
169    ) -> Transform {
170        self.animation.motion.map_or(Transform::IDENTITY, |motion| {
171            let (x, y) = match motion.origin {
172                MotionOrigin::Parent => (
173                    motion.x * slide_size.0 - center.0,
174                    motion.y * slide_size.1 - center.1,
175                ),
176                MotionOrigin::Layout => (motion.x * slide_size.0, motion.y * slide_size.1),
177            };
178            translation(x, y)
179        })
180    }
181
182    pub(crate) fn is_finite(&self) -> bool {
183        self.opacity.is_finite()
184            && transform_is_finite(self.transform)
185            && self.clip.is_none_or(rect_is_finite)
186            && self.oriented_clips.iter().all(|points| {
187                points
188                    .iter()
189                    .all(|point| point.x.is_finite() && point.y.is_finite())
190            })
191            && [
192                self.animation.scale_x,
193                self.animation.scale_y,
194                self.animation.rotation_deg,
195            ]
196            .into_iter()
197            .all(f64::is_finite)
198            && self
199                .animation
200                .motion
201                .is_none_or(|motion| motion.x.is_finite() && motion.y.is_finite())
202    }
203}
204
205/// One evaluated incoming-slide transition.
206#[derive(Clone, Debug, PartialEq)]
207pub struct EvaluatedTransition {
208    pub effect: TransitionEffect,
209    pub progress: f32,
210    pub duration_ms: u64,
211    pub parameters: Vec<TransitionParameter>,
212    pub morph_option: Option<String>,
213}
214
215/// Complete frame state at one explicit slide-local position.
216#[derive(Clone, Debug, Default, PartialEq)]
217pub struct EvaluatedFrameState {
218    pub shapes: HashMap<u32, EvaluatedShapeState>,
219    pub transition: Option<EvaluatedTransition>,
220    pub diagnostics: Vec<Diagnostic>,
221}
222
223/// Stable source identity parallel to one resolved leaf shape.
224#[derive(Clone, Debug, Eq, PartialEq)]
225pub struct ResolvedShapeIdentity {
226    pub source: FlattenedSource,
227    pub shape_id: Option<u32>,
228    pub containing_group_ids: Vec<u32>,
229    pub name: Option<String>,
230}
231
232/// A resolved slide plus timeline state parallel to its leaf shapes.
233#[derive(Clone, Debug, PartialEq)]
234pub struct ResolvedTimelineSlide {
235    pub slide: ResolvedSlide,
236    pub identities: Vec<ResolvedShapeIdentity>,
237    pub shape_states: Vec<EvaluatedShapeState>,
238    pub state: EvaluatedFrameState,
239}
240
241/// Evaluate supported timing and transition values at one explicit position.
242pub fn evaluate_timeline(
243    timing: Option<&CT_Timing>,
244    transition: Option<&CT_SlideTransition>,
245    position: TimelinePosition,
246) -> Result<EvaluatedFrameState, ResolveError> {
247    let mut state = EvaluatedFrameState {
248        transition: transition.and_then(|transition| evaluate_transition(transition, position)),
249        ..EvaluatedFrameState::default()
250    };
251    if let Some(timing) = timing {
252        let click_schedule = ClickSchedule::new(timing);
253        let mut context = EvaluationContext {
254            timing,
255            position,
256            click_schedule: &click_schedule,
257            state: &mut state,
258        };
259        evaluate_nodes(
260            timing.nodes(),
261            0,
262            u64::MAX,
263            Scheduling::Parallel,
264            &mut context,
265        );
266    }
267    let invalid_targets = state
268        .shapes
269        .iter()
270        .filter_map(|(target, evaluated)| (!evaluated.is_finite()).then_some(*target))
271        .collect::<Vec<_>>();
272    for target in invalid_targets {
273        state.shapes.insert(target, EvaluatedShapeState::default());
274        state.diagnostics.push(Diagnostic {
275            message: format!("non-finite timeline state ignored for target {target}"),
276        });
277    }
278    Ok(state)
279}
280
281/// Evaluate one media shape against the shared slide-local time and click input.
282///
283/// The returned diagnostics are scoped to this media object. Payload bytes are
284/// not inspected or decoded.
285pub fn evaluate_media_playback(
286    timing: Option<&CT_Timing>,
287    shape_id: u32,
288    trim_start_ms: Option<u64>,
289    trim_end_ms: Option<u64>,
290    position: TimelinePosition,
291) -> (EvaluatedMediaState, Vec<Diagnostic>) {
292    let trim_start = trim_start_ms.unwrap_or(0);
293    let mut diagnostics = Vec::new();
294    let mut volume = 1.0;
295    let mut looping = false;
296    let mut known_duration = None;
297    if let Some(media) = timing.and_then(|timing| timing.media_for_shape(shape_id)) {
298        volume = media.common.volume.unwrap_or(100_000).min(100_000) as f32 / 100_000.0;
299        looping = media.common.looped;
300        if let TimingDuration::Finite(duration) = media.common.common.duration {
301            known_duration = Some(duration);
302        }
303    }
304    let trim_end = trim_end_ms.or(known_duration);
305    let interval_end = trim_end.filter(|end| *end > trim_start);
306    if trim_end.is_some() && interval_end.is_none() {
307        diagnostics.push(Diagnostic {
308            message: format!(
309                "media shape {shape_id} has a trim end that is not after its trim start"
310            ),
311        });
312    }
313    if looping && interval_end.is_none() {
314        diagnostics.push(Diagnostic {
315            message: format!(
316                "looping media shape {shape_id} has no finite positive playback interval"
317            ),
318        });
319    }
320
321    let mut events = Vec::new();
322    if let Some(timing) = timing {
323        let click_schedule = ClickSchedule::new(timing);
324        collect_media_events(
325            timing,
326            timing.nodes(),
327            0,
328            u64::MAX,
329            Scheduling::Parallel,
330            shape_id,
331            position,
332            &click_schedule,
333            &mut events,
334            &mut diagnostics,
335        );
336    }
337    events.sort_by_key(|event| (event.start_ms, event.source_order));
338
339    let mut phase = MediaPlaybackPhase::Stopped;
340    let mut source_position_ms = trim_start;
341    let mut seeked_while_stopped = false;
342    let mut anchor_ms = 0;
343    for event in events
344        .into_iter()
345        .filter(|event| event.start_ms <= position.elapsed_ms)
346    {
347        let completed = advance_media_position(
348            &mut source_position_ms,
349            &mut phase,
350            anchor_ms,
351            event.start_ms,
352            trim_start,
353            interval_end,
354            looping,
355            shape_id,
356            &mut diagnostics,
357        );
358        if completed {
359            seeked_while_stopped = false;
360        }
361        anchor_ms = event.start_ms;
362        match event.command {
363            MediaCommandKind::Play { from_ms } => {
364                if let Some(from_ms) = from_ms {
365                    source_position_ms =
366                        clamp_media_position(*from_ms, trim_start, interval_end, looping);
367                } else if matches!(phase, MediaPlaybackPhase::Stopped) && !seeked_while_stopped {
368                    source_position_ms = trim_start;
369                }
370                phase = MediaPlaybackPhase::Playing;
371                seeked_while_stopped = false;
372            }
373            MediaCommandKind::Pause => {
374                if matches!(phase, MediaPlaybackPhase::Playing) {
375                    phase = MediaPlaybackPhase::Paused;
376                }
377            }
378            MediaCommandKind::Stop => {
379                phase = MediaPlaybackPhase::Stopped;
380                source_position_ms = trim_start;
381                seeked_while_stopped = false;
382            }
383            MediaCommandKind::Seek { position_ms } => {
384                source_position_ms =
385                    clamp_media_position(*position_ms, trim_start, interval_end, looping);
386                seeked_while_stopped = matches!(phase, MediaPlaybackPhase::Stopped);
387            }
388            MediaCommandKind::Other(command) => diagnostics.push(Diagnostic {
389                message: format!(
390                    "unsupported media command `{command}` for media shape {shape_id}"
391                ),
392            }),
393        }
394    }
395    advance_media_position(
396        &mut source_position_ms,
397        &mut phase,
398        anchor_ms,
399        position.elapsed_ms,
400        trim_start,
401        interval_end,
402        looping,
403        shape_id,
404        &mut diagnostics,
405    );
406    (
407        EvaluatedMediaState {
408            shape_id,
409            phase,
410            source_position_ms,
411            volume,
412            looping,
413        },
414        diagnostics,
415    )
416}
417
418#[derive(Clone, Copy)]
419struct ScheduledMediaEvent<'a> {
420    start_ms: u64,
421    source_order: usize,
422    command: &'a MediaCommandKind,
423}
424
425#[allow(clippy::too_many_arguments)]
426fn collect_media_events<'a>(
427    timing: &'a CT_Timing,
428    nodes: &'a [TimingNode],
429    parent_start: u64,
430    parent_end: u64,
431    scheduling: Scheduling,
432    shape_id: u32,
433    position: TimelinePosition,
434    click_schedule: &ClickSchedule,
435    events: &mut Vec<ScheduledMediaEvent<'a>>,
436    diagnostics: &mut Vec<Diagnostic>,
437) -> u64 {
438    let mut cursor = parent_start;
439    let mut previous = None;
440    let mut latest_end = parent_start;
441    for node in nodes {
442        let Some(common) = media_schedule_common(node) else {
443            continue;
444        };
445        if matches!(node, TimingNode::Audio(_) | TimingNode::Video(_)) {
446            continue;
447        }
448        let suggested = match (scheduling, common.node_type.as_ref(), previous) {
449            (Scheduling::Sequence, Some(TimingNodeType::WithEffect), Some((start, _))) => start,
450            (Scheduling::Sequence, Some(TimingNodeType::AfterEffect), Some((_, end))) => end,
451            (Scheduling::Sequence, _, _) => cursor,
452            (Scheduling::Parallel, _, _) => parent_start,
453        };
454        let diagnose = timing_node_contains_media_command(node, shape_id);
455        let start = scheduled_media_start(
456            timing,
457            common,
458            suggested,
459            shape_id,
460            position,
461            click_schedule,
462            diagnose,
463            diagnostics,
464        );
465        let natural_end = match common.duration {
466            TimingDuration::Finite(duration) => start.saturating_add(duration),
467            TimingDuration::Indefinite => u64::MAX,
468        }
469        .min(parent_end);
470        let end = match node {
471            TimingNode::Parallel(container) => collect_media_events(
472                timing,
473                &container.common.children,
474                start,
475                natural_end,
476                Scheduling::Parallel,
477                shape_id,
478                position,
479                click_schedule,
480                events,
481                diagnostics,
482            )
483            .min(natural_end),
484            TimingNode::Sequence(sequence) => collect_media_events(
485                timing,
486                &sequence.common.children,
487                start,
488                natural_end,
489                Scheduling::Sequence,
490                shape_id,
491                position,
492                click_schedule,
493                events,
494                diagnostics,
495            )
496            .min(natural_end),
497            TimingNode::MediaCommand(command)
498                if command.target == TimingTarget::Shape(shape_id) && start != u64::MAX =>
499            {
500                events.push(ScheduledMediaEvent {
501                    start_ms: start,
502                    source_order: events.len(),
503                    command: &command.command,
504                });
505                natural_end
506            }
507            _ => natural_end,
508        };
509        previous = Some((start, end));
510        latest_end = latest_end.max(end);
511        if matches!(scheduling, Scheduling::Sequence)
512            && !matches!(common.node_type, Some(TimingNodeType::WithEffect))
513        {
514            cursor = end;
515        }
516    }
517    latest_end.min(parent_end)
518}
519
520fn media_schedule_common(node: &TimingNode) -> Option<&CommonTimeNode> {
521    match node {
522        TimingNode::Parallel(node) => Some(&node.common),
523        TimingNode::Sequence(node) => Some(&node.common),
524        TimingNode::Set(node) => Some(&node.common),
525        TimingNode::Animate(node) => Some(&node.common),
526        TimingNode::Effect(node) => Some(&node.common),
527        TimingNode::Motion(node) => Some(&node.common),
528        TimingNode::Audio(node) | TimingNode::Video(node) => Some(&node.common.common),
529        TimingNode::MediaCommand(node) => Some(&node.common),
530        TimingNode::Unsupported(_) => None,
531    }
532}
533
534fn timing_node_contains_media_command(node: &TimingNode, shape_id: u32) -> bool {
535    if matches!(node, TimingNode::MediaCommand(command) if command.target == TimingTarget::Shape(shape_id))
536    {
537        return true;
538    }
539    media_schedule_common(node).is_some_and(|common| {
540        common
541            .children
542            .iter()
543            .any(|child| timing_node_contains_media_command(child, shape_id))
544    })
545}
546
547#[allow(clippy::too_many_arguments)]
548fn scheduled_media_start(
549    timing: &CT_Timing,
550    common: &CommonTimeNode,
551    suggested: u64,
552    shape_id: u32,
553    position: TimelinePosition,
554    click_schedule: &ClickSchedule,
555    diagnose: bool,
556    diagnostics: &mut Vec<Diagnostic>,
557) -> u64 {
558    let click_effect = matches!(common.node_type, Some(TimingNodeType::ClickEffect));
559    let click_available = click_schedule.is_available(common, position.click_count);
560    if click_effect && !click_available {
561        return u64::MAX;
562    }
563    if common.start_conditions.is_empty() {
564        return suggested;
565    }
566    let start = common
567        .start_conditions
568        .iter()
569        .enumerate()
570        .filter(|(_, condition)| {
571            !matches!(
572                condition.event,
573                Some(TimingEvent::OnClick | TimingEvent::OnNext)
574            ) || click_available
575        })
576        .filter_map(|(index, condition)| {
577            let supported_event = matches!(
578                condition.event,
579                None | Some(TimingEvent::OnBegin)
580                    | Some(TimingEvent::OnClick)
581                    | Some(TimingEvent::OnNext)
582            );
583            let supported_target = matches!(condition.target, TimingTarget::Slide)
584                || matches!(condition.target, TimingTarget::Shape(id) if id == shape_id)
585                || matches!(condition.target, TimingTarget::Unsupported)
586                    && timing.condition_has_explicit_target(common.id, false, index) == Some(false);
587            if !supported_event || !supported_target {
588                if diagnose {
589                    diagnostics.push(Diagnostic {
590                        message: format!("unsupported playback trigger for media shape {shape_id}"),
591                    });
592                }
593                return None;
594            }
595            match condition.delay {
596                TimingDuration::Finite(delay) => suggested.checked_add(delay).or_else(|| {
597                    if diagnose {
598                        diagnostics.push(Diagnostic {
599                            message: format!(
600                                "playback trigger overflow for media shape {shape_id}"
601                            ),
602                        });
603                    }
604                    None
605                }),
606                TimingDuration::Indefinite => None,
607            }
608        })
609        .min();
610    start.unwrap_or(u64::MAX)
611}
612
613#[allow(clippy::too_many_arguments)]
614fn advance_media_position(
615    source_position_ms: &mut u64,
616    phase: &mut MediaPlaybackPhase,
617    anchor_ms: u64,
618    target_ms: u64,
619    trim_start: u64,
620    interval_end: Option<u64>,
621    looping: bool,
622    shape_id: u32,
623    diagnostics: &mut Vec<Diagnostic>,
624) -> bool {
625    if !matches!(phase, MediaPlaybackPhase::Playing) || target_ms < anchor_ms {
626        return false;
627    }
628    let delta = target_ms - anchor_ms;
629    let advanced = source_position_ms.checked_add(delta).unwrap_or_else(|| {
630        diagnostics.push(Diagnostic {
631            message: format!("media position overflow clamped for media shape {shape_id}"),
632        });
633        u64::MAX
634    });
635    *source_position_ms = clamp_media_position(advanced, trim_start, interval_end, looping);
636    if !looping && interval_end.is_some_and(|end| advanced >= end) {
637        *phase = MediaPlaybackPhase::Stopped;
638        return true;
639    }
640    false
641}
642
643fn clamp_media_position(
644    position: u64,
645    trim_start: u64,
646    interval_end: Option<u64>,
647    looping: bool,
648) -> u64 {
649    let position = position.max(trim_start);
650    let Some(end) = interval_end else {
651        return position;
652    };
653    if looping && position >= end {
654        return trim_start + (position - trim_start) % (end - trim_start);
655    }
656    position.min(end)
657}
658
659#[derive(Clone, Copy)]
660enum Scheduling {
661    Parallel,
662    Sequence,
663}
664
665#[derive(Default)]
666struct ClickSchedule {
667    ordinals: HashMap<*const CommonTimeNode, u32>,
668}
669
670impl ClickSchedule {
671    fn new(timing: &CT_Timing) -> Self {
672        let mut schedule = Self::default();
673        let mut ordinal = 0;
674        collect_click_schedule(timing.nodes(), &mut ordinal, &mut schedule.ordinals);
675        schedule
676    }
677
678    fn is_available(&self, common: &CommonTimeNode, click_count: u32) -> bool {
679        self.ordinals
680            .get(&std::ptr::from_ref(common))
681            .is_some_and(|ordinal| click_count >= *ordinal)
682    }
683}
684
685fn collect_click_schedule(
686    nodes: &[TimingNode],
687    ordinal: &mut u32,
688    ordinals: &mut HashMap<*const CommonTimeNode, u32>,
689) {
690    for node in nodes {
691        if matches!(node, TimingNode::Audio(_) | TimingNode::Video(_)) {
692            continue;
693        }
694        let Some(common) = media_schedule_common(node) else {
695            continue;
696        };
697        if common_uses_click(common) {
698            *ordinal = ordinal.saturating_add(1);
699            ordinals.insert(std::ptr::from_ref(common), *ordinal);
700        }
701        if matches!(node, TimingNode::Parallel(_) | TimingNode::Sequence(_)) {
702            collect_click_schedule(&common.children, ordinal, ordinals);
703        }
704    }
705}
706
707fn common_uses_click(common: &CommonTimeNode) -> bool {
708    matches!(common.node_type, Some(TimingNodeType::ClickEffect))
709        || common.start_conditions.iter().any(|condition| {
710            matches!(
711                condition.event,
712                Some(TimingEvent::OnClick | TimingEvent::OnNext)
713            )
714        })
715}
716
717struct EvaluationContext<'a> {
718    timing: &'a CT_Timing,
719    position: TimelinePosition,
720    click_schedule: &'a ClickSchedule,
721    state: &'a mut EvaluatedFrameState,
722}
723
724fn evaluate_nodes(
725    nodes: &[TimingNode],
726    parent_start: u64,
727    parent_end: u64,
728    scheduling: Scheduling,
729    context: &mut EvaluationContext<'_>,
730) -> u64 {
731    let mut cursor = parent_start;
732    let mut previous = None;
733    let mut latest_end = parent_start;
734    for node in nodes {
735        if let TimingNode::Unsupported(node) = node {
736            context.state.diagnostics.push(Diagnostic {
737                message: format!("unsupported timing node {}", node.local_name),
738            });
739            continue;
740        }
741        if matches!(
742            node,
743            TimingNode::Audio(_) | TimingNode::Video(_) | TimingNode::MediaCommand(_)
744        ) {
745            context.state.diagnostics.push(Diagnostic {
746                message: "media timing is retained for synchronized playback".to_owned(),
747            });
748            continue;
749        }
750        let common = common(node);
751        let suggested = match (scheduling, common.node_type.as_ref(), previous) {
752            (Scheduling::Sequence, Some(TimingNodeType::WithEffect), Some((start, _))) => start,
753            (Scheduling::Sequence, Some(TimingNodeType::AfterEffect), Some((_, end))) => end,
754            (Scheduling::Sequence, _, _) => cursor,
755            (Scheduling::Parallel, _, _) => parent_start,
756        };
757        let start = scheduled_start(common, suggested, context);
758        let end = evaluate_node(node, start, parent_end, context);
759        previous = Some((start, end));
760        latest_end = latest_end.max(end);
761        if matches!(scheduling, Scheduling::Sequence)
762            && !matches!(common.node_type, Some(TimingNodeType::WithEffect))
763        {
764            cursor = end;
765        }
766    }
767    latest_end.min(parent_end)
768}
769
770fn common(node: &TimingNode) -> &CommonTimeNode {
771    match node {
772        TimingNode::Parallel(node) => &node.common,
773        TimingNode::Sequence(node) => &node.common,
774        TimingNode::Set(node) => &node.common,
775        TimingNode::Animate(node) => &node.common,
776        TimingNode::Effect(node) => &node.common,
777        TimingNode::Motion(node) => &node.common,
778        TimingNode::Audio(_) | TimingNode::Video(_) | TimingNode::MediaCommand(_) => {
779            unreachable!("media nodes are diagnosed by the caller")
780        }
781        TimingNode::Unsupported(_) => unreachable!("unsupported nodes are diagnosed by the caller"),
782    }
783}
784
785fn scheduled_start(
786    common: &CommonTimeNode,
787    suggested: u64,
788    context: &mut EvaluationContext<'_>,
789) -> u64 {
790    let click_effect = matches!(common.node_type, Some(TimingNodeType::ClickEffect));
791    let click_available = context
792        .click_schedule
793        .is_available(common, context.position.click_count);
794    if click_effect && !click_available {
795        return u64::MAX;
796    }
797    if common.start_conditions.is_empty() {
798        return suggested;
799    }
800    common
801        .start_conditions
802        .iter()
803        .enumerate()
804        .filter(|(_, condition)| {
805            !matches!(
806                condition.event,
807                Some(TimingEvent::OnClick | TimingEvent::OnNext)
808            ) || click_available
809        })
810        .filter_map(|(index, condition)| {
811            condition_start(
812                condition,
813                suggested,
814                context
815                    .timing
816                    .condition_has_explicit_target(common.id, false, index),
817                context.state,
818            )
819        })
820        .min()
821        .unwrap_or(u64::MAX)
822}
823
824fn condition_start(
825    condition: &TimingCondition,
826    suggested: u64,
827    explicit_target: Option<bool>,
828    state: &mut EvaluatedFrameState,
829) -> Option<u64> {
830    let supported_event = matches!(
831        condition.event,
832        None | Some(TimingEvent::OnBegin) | Some(TimingEvent::OnClick) | Some(TimingEvent::OnNext)
833    );
834    let supported_target = matches!(condition.target, TimingTarget::Slide)
835        || matches!(condition.target, TimingTarget::Unsupported) && explicit_target == Some(false);
836    if !supported_event || !supported_target {
837        state.diagnostics.push(Diagnostic {
838            message: "unsupported timing condition trigger".to_owned(),
839        });
840        return None;
841    }
842    match condition.delay {
843        TimingDuration::Finite(delay) => Some(suggested.saturating_add(delay)),
844        TimingDuration::Indefinite => None,
845    }
846}
847
848fn evaluate_node(
849    node: &TimingNode,
850    start: u64,
851    parent_end: u64,
852    context: &mut EvaluationContext<'_>,
853) -> u64 {
854    let common = common(node);
855    let condition_end = declared_condition_end(common, start, context);
856    let container_end = match common.duration {
857        TimingDuration::Finite(duration) => start.saturating_add(duration),
858        TimingDuration::Indefinite => u64::MAX,
859    }
860    .min(condition_end.unwrap_or(u64::MAX))
861    .min(parent_end);
862    match node {
863        TimingNode::Parallel(TimingContainer { common }) => {
864            let child_end = evaluate_nodes(
865                &common.children,
866                start,
867                container_end,
868                Scheduling::Parallel,
869                context,
870            );
871            declared_end(common, start, child_end, parent_end, condition_end)
872        }
873        TimingNode::Sequence(TimingSequence { common, .. }) => {
874            let child_end = evaluate_nodes(
875                &common.children,
876                start,
877                container_end,
878                Scheduling::Sequence,
879                context,
880            );
881            declared_end(common, start, child_end, parent_end, condition_end)
882        }
883        TimingNode::Set(node) => {
884            let phase_end = declared_end(&node.common, start, start, u64::MAX, condition_end);
885            let end = phase_end.min(parent_end);
886            if start < parent_end && context.position.elapsed_ms <= parent_end {
887                evaluate_set(node, start, phase_end, context);
888            }
889            end
890        }
891        TimingNode::Animate(node) => {
892            let phase_end = declared_end(&node.common, start, start, u64::MAX, condition_end);
893            let end = phase_end.min(parent_end);
894            if start < parent_end && context.position.elapsed_ms <= parent_end {
895                evaluate_animate(node, start, phase_end, context);
896            }
897            end
898        }
899        TimingNode::Effect(node) => {
900            let phase_end = declared_end(&node.common, start, start, u64::MAX, condition_end);
901            let end = phase_end.min(parent_end);
902            if start < parent_end && context.position.elapsed_ms <= parent_end {
903                evaluate_effect(node, start, phase_end, context);
904            }
905            end
906        }
907        TimingNode::Motion(node) => {
908            let phase_end = declared_end(&node.common, start, start, u64::MAX, condition_end);
909            let end = phase_end.min(parent_end);
910            if start < parent_end && context.position.elapsed_ms <= parent_end {
911                evaluate_motion(node, start, phase_end, context);
912            }
913            end
914        }
915        TimingNode::Audio(_) | TimingNode::Video(_) | TimingNode::MediaCommand(_) => start,
916        TimingNode::Unsupported(_) => start,
917    }
918}
919
920fn declared_end(
921    common: &CommonTimeNode,
922    start: u64,
923    child_end: u64,
924    parent_end: u64,
925    condition_end: Option<u64>,
926) -> u64 {
927    let natural_end = match common.duration {
928        TimingDuration::Finite(duration) => start.saturating_add(duration),
929        TimingDuration::Indefinite if child_end > start => child_end,
930        TimingDuration::Indefinite => u64::MAX,
931    };
932    natural_end
933        .min(condition_end.unwrap_or(u64::MAX))
934        .min(parent_end)
935}
936
937fn declared_condition_end(
938    common: &CommonTimeNode,
939    start: u64,
940    context: &mut EvaluationContext<'_>,
941) -> Option<u64> {
942    common
943        .end_conditions
944        .iter()
945        .enumerate()
946        .filter_map(|(index, condition)| {
947            end_condition_time(
948                condition,
949                start,
950                context
951                    .timing
952                    .condition_has_explicit_target(common.id, true, index),
953                context.state,
954            )
955        })
956        .min()
957}
958
959fn end_condition_time(
960    condition: &TimingCondition,
961    start: u64,
962    explicit_target: Option<bool>,
963    state: &mut EvaluatedFrameState,
964) -> Option<u64> {
965    if !matches!(condition.event, None | Some(TimingEvent::OnBegin))
966        || !(matches!(condition.target, TimingTarget::Slide)
967            || matches!(condition.target, TimingTarget::Unsupported)
968                && explicit_target == Some(false))
969    {
970        state.diagnostics.push(Diagnostic {
971            message: "unsupported timing condition trigger".to_owned(),
972        });
973        return None;
974    }
975    match condition.delay {
976        TimingDuration::Finite(delay) => Some(start.saturating_add(delay)),
977        TimingDuration::Indefinite => None,
978    }
979}
980
981#[derive(Clone, Copy)]
982struct Phase {
983    progress: f64,
984    applies: bool,
985    before: bool,
986}
987
988fn phase(common: &CommonTimeNode, start: u64, end: u64, elapsed: u64) -> Phase {
989    if start == u64::MAX || elapsed < start {
990        return Phase {
991            progress: 0.0,
992            applies: false,
993            before: true,
994        };
995    }
996    if end == u64::MAX {
997        return Phase {
998            progress: 0.0,
999            applies: true,
1000            before: false,
1001        };
1002    }
1003    let duration = end.saturating_sub(start);
1004    if elapsed <= end {
1005        return Phase {
1006            progress: if duration == 0 {
1007                1.0
1008            } else {
1009                (elapsed - start) as f64 / duration as f64
1010            }
1011            .clamp(0.0, 1.0),
1012            applies: true,
1013            before: false,
1014        };
1015    }
1016    Phase {
1017        progress: 1.0,
1018        applies: matches!(common.fill, Some(TimingFill::Hold | TimingFill::Freeze)),
1019        before: false,
1020    }
1021}
1022
1023fn target_shape(target: &TimingTarget, state: &mut EvaluatedFrameState) -> Option<u32> {
1024    match target {
1025        TimingTarget::Shape(id) => Some(*id),
1026        _ => {
1027            state.diagnostics.push(Diagnostic {
1028                message: "unsupported non-shape timing target".to_owned(),
1029            });
1030            None
1031        }
1032    }
1033}
1034
1035fn shape_state(state: &mut EvaluatedFrameState, id: u32) -> &mut EvaluatedShapeState {
1036    state.shapes.entry(id).or_default()
1037}
1038
1039fn evaluate_set(node: &TimingSet, start: u64, end: u64, context: &mut EvaluationContext<'_>) {
1040    let phase = phase(&node.common, start, end, context.position.elapsed_ms);
1041    if !phase.applies {
1042        return;
1043    }
1044    let Some(id) = target_shape(&node.target, context.state) else {
1045        return;
1046    };
1047    let value = node.value.as_deref().unwrap_or_default();
1048    match node.attribute_name.as_deref().unwrap_or_default() {
1049        "style.visibility" | "visibility" => {
1050            shape_state(context.state, id).visible = !matches!(value, "hidden" | "false" | "0")
1051        }
1052        "style.opacity" | "opacity" => {
1053            if let Some(value) = parse_number(value) {
1054                shape_state(context.state, id).opacity = normalize_fraction(value) as f32;
1055            } else {
1056                context.state.diagnostics.push(Diagnostic {
1057                    message: "invalid non-finite set value ignored".to_owned(),
1058                });
1059            }
1060        }
1061        attribute => context.state.diagnostics.push(Diagnostic {
1062            message: format!("unsupported set attribute {attribute}"),
1063        }),
1064    }
1065}
1066
1067fn evaluate_animate(
1068    node: &TimingAnimate,
1069    start: u64,
1070    end: u64,
1071    context: &mut EvaluationContext<'_>,
1072) {
1073    let phase = phase(&node.common, start, end, context.position.elapsed_ms);
1074    if !phase.applies {
1075        return;
1076    }
1077    let Some(id) = target_shape(&node.target, context.state) else {
1078        return;
1079    };
1080    if node
1081        .from
1082        .iter()
1083        .chain(&node.to)
1084        .chain(&node.by)
1085        .any(|value| parse_number(value).is_none())
1086    {
1087        context.state.diagnostics.push(Diagnostic {
1088            message: "invalid non-finite animate value ignored".to_owned(),
1089        });
1090        return;
1091    }
1092    let from = node.from.as_deref().and_then(parse_number).unwrap_or(0.0);
1093    let to = if let Some(to) = node.to.as_deref() {
1094        parse_number(to)
1095    } else if let Some(by) = node.by.as_deref() {
1096        parse_number(by)
1097            .map(|by| from + by)
1098            .filter(|value| value.is_finite())
1099    } else {
1100        Some(from)
1101    };
1102    let Some(value) = to.and_then(|to| checked_interpolate(from, to, phase.progress)) else {
1103        context.state.diagnostics.push(Diagnostic {
1104            message: "non-finite animate arithmetic ignored".to_owned(),
1105        });
1106        return;
1107    };
1108    match node.attribute_name.as_deref().unwrap_or_default() {
1109        "style.opacity" | "opacity" => {
1110            shape_state(context.state, id).opacity = normalize_fraction(value) as f32
1111        }
1112        "scale" => {
1113            let state = shape_state(context.state, id);
1114            let value = normalize_percentage(value);
1115            state.animation.scale_x = value;
1116            state.animation.scale_y = value;
1117            state.rebuild_public_transform();
1118        }
1119        "ppt_x" | "ppt_y" => context.state.diagnostics.push(Diagnostic {
1120            message: format!(
1121                "unsupported animate attribute {}",
1122                node.attribute_name.as_deref().unwrap_or_default()
1123            ),
1124        }),
1125        "rotation" | "spin" => {
1126            let state = shape_state(context.state, id);
1127            let degrees = if value.abs() > 360.0 {
1128                value / 60_000.0
1129            } else {
1130                value
1131            };
1132            state.animation.rotation_deg = degrees;
1133            state.rebuild_public_transform();
1134        }
1135        attribute => context.state.diagnostics.push(Diagnostic {
1136            message: format!("unsupported animate attribute {attribute}"),
1137        }),
1138    }
1139}
1140
1141fn evaluate_effect(node: &TimingEffect, start: u64, end: u64, context: &mut EvaluationContext<'_>) {
1142    let phase = phase(&node.common, start, end, context.position.elapsed_ms);
1143    let Some(id) = target_shape(&node.target, context.state) else {
1144        return;
1145    };
1146    let transition = node.transition.as_deref().unwrap_or("in");
1147    if !matches!(transition, "in" | "out") {
1148        context.state.diagnostics.push(Diagnostic {
1149            message: format!("unsupported timing effect transition {transition}"),
1150        });
1151        return;
1152    }
1153    let filter = node.filter.as_deref().unwrap_or("fade");
1154    let Some(effect) = shape_effect(filter) else {
1155        context.state.diagnostics.push(Diagnostic {
1156            message: format!("unsupported timing effect {filter}"),
1157        });
1158        return;
1159    };
1160    let state = shape_state(context.state, id);
1161    if phase.before {
1162        if transition == "in" {
1163            state.visible = false;
1164            state.opacity = 0.0;
1165        }
1166        return;
1167    }
1168    if !phase.applies {
1169        return;
1170    }
1171    let progress = if matches!(effect, ShapeEffect::Appear) {
1172        1.0
1173    } else {
1174        phase.progress
1175    };
1176    match (effect, transition == "out") {
1177        (ShapeEffect::Appear, true) => {
1178            state.opacity = 0.0;
1179            state.visible = false;
1180        }
1181        (ShapeEffect::Appear, false) => {
1182            state.visible = true;
1183            state.opacity = 1.0;
1184        }
1185        (ShapeEffect::Fade, true) => {
1186            state.opacity = (1.0 - progress) as f32;
1187            state.visible = progress < 1.0;
1188        }
1189        (ShapeEffect::Fade, false) => {
1190            state.visible = true;
1191            state.opacity = progress as f32;
1192        }
1193        (ShapeEffect::Wipe(direction), exiting) => {
1194            state.visible = !exiting || progress < 1.0;
1195            state.opacity = 1.0;
1196            state.clip = Some(wipe_clip(direction, progress, exiting));
1197        }
1198    }
1199}
1200
1201#[derive(Clone, Copy)]
1202enum ShapeEffect<'a> {
1203    Appear,
1204    Fade,
1205    Wipe(&'a str),
1206}
1207
1208fn shape_effect(filter: &str) -> Option<ShapeEffect<'_>> {
1209    match filter.trim() {
1210        "appear" => Some(ShapeEffect::Appear),
1211        "fade" => Some(ShapeEffect::Fade),
1212        filter => filter
1213            .strip_prefix("wipe(")
1214            .and_then(|direction| direction.strip_suffix(')'))
1215            .filter(|direction| matches!(*direction, "left" | "right" | "up" | "down"))
1216            .map(ShapeEffect::Wipe),
1217    }
1218}
1219
1220fn wipe_clip(direction: &str, progress: f64, exiting: bool) -> Rect {
1221    let progress = progress.clamp(0.0, 1.0);
1222    match (direction, exiting) {
1223        ("left", false) => Rect {
1224            x: 1.0 - progress,
1225            y: 0.0,
1226            width: progress,
1227            height: 1.0,
1228        },
1229        ("up", false) => Rect {
1230            x: 0.0,
1231            y: 1.0 - progress,
1232            width: 1.0,
1233            height: progress,
1234        },
1235        ("down", false) => Rect {
1236            x: 0.0,
1237            y: 0.0,
1238            width: 1.0,
1239            height: progress,
1240        },
1241        ("right", false) => Rect {
1242            x: 0.0,
1243            y: 0.0,
1244            width: progress,
1245            height: 1.0,
1246        },
1247        ("left", true) => Rect {
1248            x: 0.0,
1249            y: 0.0,
1250            width: 1.0 - progress,
1251            height: 1.0,
1252        },
1253        ("up", true) => Rect {
1254            x: 0.0,
1255            y: 0.0,
1256            width: 1.0,
1257            height: 1.0 - progress,
1258        },
1259        ("down", true) => Rect {
1260            x: 0.0,
1261            y: progress,
1262            width: 1.0,
1263            height: 1.0 - progress,
1264        },
1265        _ => Rect {
1266            x: progress,
1267            y: 0.0,
1268            width: 1.0 - progress,
1269            height: 1.0,
1270        },
1271    }
1272}
1273
1274fn evaluate_motion(
1275    node: &TimingMotionPath,
1276    start: u64,
1277    end: u64,
1278    context: &mut EvaluationContext<'_>,
1279) {
1280    let phase = phase(&node.common, start, end, context.position.elapsed_ms);
1281    if !phase.applies {
1282        return;
1283    }
1284    let Some(id) = target_shape(&node.target, context.state) else {
1285        return;
1286    };
1287    let Some(points) = node.path.as_deref().and_then(parse_motion_path) else {
1288        context.state.diagnostics.push(Diagnostic {
1289            message: "unsupported motion path".to_owned(),
1290        });
1291        return;
1292    };
1293    let (x, y) = point_on_polyline(&points, phase.progress);
1294    if !x.is_finite() || !y.is_finite() {
1295        context.state.diagnostics.push(Diagnostic {
1296            message: "non-finite motion path ignored".to_owned(),
1297        });
1298        return;
1299    }
1300    let origin = match node.origin.as_deref().unwrap_or("parent") {
1301        "parent" => MotionOrigin::Parent,
1302        "layout" => MotionOrigin::Layout,
1303        value => {
1304            context.state.diagnostics.push(Diagnostic {
1305                message: format!("unsupported motion path origin {value}"),
1306            });
1307            return;
1308        }
1309    };
1310    let state = shape_state(context.state, id);
1311    state.animation.motion = Some(EvaluatedMotion { x, y, origin });
1312    state.rebuild_public_transform();
1313}
1314
1315fn parse_number(value: &str) -> Option<f64> {
1316    value
1317        .trim()
1318        .parse::<f64>()
1319        .ok()
1320        .filter(|value| value.is_finite())
1321}
1322
1323fn normalize_fraction(value: f64) -> f64 {
1324    normalize_percentage(value).clamp(0.0, 1.0)
1325}
1326
1327fn normalize_percentage(value: f64) -> f64 {
1328    if value.abs() > 10.0 {
1329        value / 100_000.0
1330    } else {
1331        value
1332    }
1333}
1334
1335fn interpolate(from: f64, to: f64, progress: f64) -> f64 {
1336    from + (to - from) * progress
1337}
1338
1339fn checked_interpolate(from: f64, to: f64, progress: f64) -> Option<f64> {
1340    let value = from + (to - from) * progress;
1341    value.is_finite().then_some(value)
1342}
1343
1344fn parse_motion_path(path: &str) -> Option<Vec<(f64, f64)>> {
1345    #[derive(Clone, Copy)]
1346    enum Command {
1347        MoveAbsolute,
1348        LineAbsolute,
1349        MoveRelative,
1350        LineRelative,
1351    }
1352
1353    let tokens = path
1354        .split(|character: char| character.is_ascii_whitespace() || character == ',')
1355        .filter(|token| !token.is_empty())
1356        .collect::<Vec<_>>();
1357    let mut index = 0;
1358    let mut command = None;
1359    let mut current = (0.0, 0.0);
1360    let mut points = Vec::new();
1361    while index < tokens.len() {
1362        command = match tokens[index] {
1363            "M" => Some(Command::MoveAbsolute),
1364            "L" => Some(Command::LineAbsolute),
1365            "m" => Some(Command::MoveRelative),
1366            "l" => Some(Command::LineRelative),
1367            "E" | "e" => break,
1368            _ => command,
1369        };
1370        if matches!(tokens[index], "M" | "L" | "m" | "l") {
1371            index += 1;
1372            continue;
1373        }
1374        let active = command?;
1375        let x = parse_number(tokens[index])?;
1376        let y = parse_number(tokens.get(index + 1)?)?;
1377        current = match active {
1378            Command::MoveAbsolute | Command::LineAbsolute => (x, y),
1379            Command::MoveRelative | Command::LineRelative => (current.0 + x, current.1 + y),
1380        };
1381        if !current.0.is_finite() || !current.1.is_finite() {
1382            return None;
1383        }
1384        points.push(current);
1385        command = Some(match active {
1386            Command::MoveAbsolute => Command::LineAbsolute,
1387            Command::MoveRelative => Command::LineRelative,
1388            command => command,
1389        });
1390        index += 2;
1391    }
1392    if points.len() < 2 {
1393        return None;
1394    }
1395    Some(points)
1396}
1397
1398fn point_on_polyline(points: &[(f64, f64)], progress: f64) -> (f64, f64) {
1399    let lengths = points
1400        .windows(2)
1401        .map(|pair| (pair[1].0 - pair[0].0).hypot(pair[1].1 - pair[0].1))
1402        .collect::<Vec<_>>();
1403    let total = lengths.iter().sum::<f64>();
1404    if total <= f64::EPSILON {
1405        return points[0];
1406    }
1407    let mut remaining = total * progress.clamp(0.0, 1.0);
1408    for (index, length) in lengths.iter().copied().enumerate() {
1409        if remaining <= length || index + 1 == lengths.len() {
1410            let local = if length <= f64::EPSILON {
1411                0.0
1412            } else {
1413                remaining / length
1414            };
1415            return (
1416                interpolate(points[index].0, points[index + 1].0, local),
1417                interpolate(points[index].1, points[index + 1].1, local),
1418            );
1419        }
1420        remaining -= length;
1421    }
1422    *points
1423        .last()
1424        .expect("motion paths have at least two points")
1425}
1426
1427fn scale_about(scale_x: f64, scale_y: f64, center_x: f64, center_y: f64) -> Transform {
1428    Transform {
1429        a: scale_x,
1430        d: scale_y,
1431        e: center_x * (1.0 - scale_x),
1432        f: center_y * (1.0 - scale_y),
1433        ..Transform::IDENTITY
1434    }
1435}
1436
1437fn translation(x: f64, y: f64) -> Transform {
1438    Transform {
1439        e: x,
1440        f: y,
1441        ..Transform::IDENTITY
1442    }
1443}
1444
1445fn transform_is_finite(transform: Transform) -> bool {
1446    [
1447        transform.a,
1448        transform.b,
1449        transform.c,
1450        transform.d,
1451        transform.e,
1452        transform.f,
1453    ]
1454    .into_iter()
1455    .all(f64::is_finite)
1456}
1457
1458fn rect_is_finite(rect: Rect) -> bool {
1459    [rect.x, rect.y, rect.width, rect.height]
1460        .into_iter()
1461        .all(f64::is_finite)
1462}
1463
1464fn evaluate_transition(
1465    transition: &CT_SlideTransition,
1466    position: TimelinePosition,
1467) -> Option<EvaluatedTransition> {
1468    let effect = transition.effect.clone()?;
1469    let duration_ms = transition.duration_ms.unwrap_or(match transition.speed {
1470        Some(TransitionSpeed::Slow) => 1_000,
1471        Some(TransitionSpeed::Fast) => 500,
1472        _ => 750,
1473    });
1474    let progress = if duration_ms == 0 {
1475        1.0
1476    } else {
1477        position.elapsed_ms as f64 / duration_ms as f64
1478    }
1479    .clamp(0.0, 1.0) as f32;
1480    Some(EvaluatedTransition {
1481        effect,
1482        progress,
1483        duration_ms,
1484        parameters: transition.effect_parameters.clone(),
1485        morph_option: transition
1486            .morph
1487            .as_ref()
1488            .and_then(|morph| morph.option.clone()),
1489    })
1490}
1491
1492#[cfg(test)]
1493mod tests {
1494    use super::{MediaPlaybackPhase, TimelinePosition, evaluate_media_playback, evaluate_timeline};
1495    use oxml_layout::Point;
1496    use rpptx_oxml::timing::CT_Timing;
1497
1498    const P_NS: &str = "http://schemas.openxmlformats.org/presentationml/2006/main";
1499
1500    fn timing(nodes: &str) -> CT_Timing {
1501        CT_Timing::from_xml(
1502            format!(r#"<p:timing xmlns:p="{P_NS}"><p:tnLst>{nodes}</p:tnLst></p:timing>"#)
1503                .as_bytes(),
1504        )
1505        .unwrap()
1506    }
1507
1508    #[test]
1509    fn media_playback_state_clamps_trim_volume_and_exact_boundaries() {
1510        let timing = timing(
1511            r#"<p:video><p:cMediaNode vol="25000"><p:cTn id="1" dur="1000" repeatCount="indefinite"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cMediaNode></p:video>
1512            <p:cmd type="call" cmd="playFrom(0.0)"><p:cBhvr><p:cTn id="2" dur="1"><p:stCondLst><p:cond delay="100"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>
1513            <p:cmd type="call" cmd="pause"><p:cBhvr><p:cTn id="3" dur="1"><p:stCondLst><p:cond delay="300"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>
1514            <p:cmd type="call" cmd="playFrom(0.2)"><p:cBhvr><p:cTn id="4" dur="1"><p:stCondLst><p:cond delay="400"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>
1515            <p:cmd type="call" cmd="stop"><p:cBhvr><p:cTn id="5" dur="1"><p:stCondLst><p:cond delay="700"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>"#,
1516        );
1517        let at = |elapsed_ms| {
1518            evaluate_media_playback(
1519                Some(&timing),
1520                7,
1521                Some(50),
1522                Some(250),
1523                TimelinePosition {
1524                    elapsed_ms,
1525                    click_count: 0,
1526                },
1527            )
1528        };
1529
1530        assert_eq!(at(99).0.phase, MediaPlaybackPhase::Stopped);
1531        assert_eq!(at(100).0.source_position_ms, 50);
1532        assert_eq!(at(300).0.phase, MediaPlaybackPhase::Paused);
1533        assert_eq!(at(300).0.source_position_ms, 50);
1534        assert_eq!(at(400).0.source_position_ms, 200);
1535        assert_eq!(at(450).0.source_position_ms, 50);
1536        assert_eq!(at(700).0.phase, MediaPlaybackPhase::Stopped);
1537        assert_eq!(at(700).0.source_position_ms, 50);
1538        assert_eq!(at(450).0.volume, 0.25);
1539        assert!(at(450).0.looping);
1540        assert!(at(450).1.is_empty());
1541    }
1542
1543    #[test]
1544    fn seek_while_stopped_is_preserved_by_a_following_offset_free_play() {
1545        let timing = timing(
1546            r#"<p:cmd type="call" cmd="seek(0.2)"><p:cBhvr><p:cTn id="1" dur="1"><p:stCondLst><p:cond delay="100"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>
1547            <p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="2" dur="1"><p:stCondLst><p:cond delay="200"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>
1548            <p:cmd type="call" cmd="stop"><p:cBhvr><p:cTn id="3" dur="1"><p:stCondLst><p:cond delay="300"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>
1549            <p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="4" dur="1"><p:stCondLst><p:cond delay="400"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>"#,
1550        );
1551        let at = |elapsed_ms| {
1552            evaluate_media_playback(
1553                Some(&timing),
1554                7,
1555                Some(50),
1556                Some(500),
1557                TimelinePosition {
1558                    elapsed_ms,
1559                    click_count: 0,
1560                },
1561            )
1562            .0
1563        };
1564
1565        assert_eq!(at(99).source_position_ms, 50);
1566        assert_eq!(at(100).source_position_ms, 200);
1567        assert_eq!(at(200).phase, MediaPlaybackPhase::Playing);
1568        assert_eq!(at(200).source_position_ms, 200);
1569        assert_eq!(at(250).source_position_ms, 250);
1570        assert_eq!(at(300).phase, MediaPlaybackPhase::Stopped);
1571        assert_eq!(at(300).source_position_ms, 50);
1572        assert_eq!(at(400).phase, MediaPlaybackPhase::Playing);
1573        assert_eq!(at(400).source_position_ms, 50);
1574        assert_eq!(at(450).source_position_ms, 100);
1575    }
1576
1577    #[test]
1578    fn finite_non_looping_playback_stops_at_exact_end_boundaries() {
1579        let trimmed = timing(
1580            r#"<p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="1" dur="1"><p:stCondLst><p:cond delay="100"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>"#,
1581        );
1582        let trimmed_at = |elapsed_ms| {
1583            evaluate_media_playback(
1584                Some(&trimmed),
1585                7,
1586                Some(50),
1587                Some(250),
1588                TimelinePosition {
1589                    elapsed_ms,
1590                    click_count: 0,
1591                },
1592            )
1593            .0
1594        };
1595
1596        assert_eq!(trimmed_at(299).phase, MediaPlaybackPhase::Playing);
1597        assert_eq!(trimmed_at(299).source_position_ms, 249);
1598        assert_eq!(trimmed_at(300).phase, MediaPlaybackPhase::Stopped);
1599        assert_eq!(trimmed_at(300).source_position_ms, 250);
1600        assert_eq!(trimmed_at(301).phase, MediaPlaybackPhase::Stopped);
1601        assert_eq!(trimmed_at(301).source_position_ms, 250);
1602
1603        let known_duration = timing(
1604            r#"<p:video><p:cMediaNode vol="100000"><p:cTn id="1" dur="250"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cMediaNode></p:video>
1605            <p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="2" dur="1"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>"#,
1606        );
1607        let duration_at = |elapsed_ms| {
1608            evaluate_media_playback(
1609                Some(&known_duration),
1610                7,
1611                None,
1612                None,
1613                TimelinePosition {
1614                    elapsed_ms,
1615                    click_count: 0,
1616                },
1617            )
1618            .0
1619        };
1620
1621        assert_eq!(duration_at(249).phase, MediaPlaybackPhase::Playing);
1622        assert_eq!(duration_at(249).source_position_ms, 249);
1623        assert_eq!(duration_at(250).phase, MediaPlaybackPhase::Stopped);
1624        assert_eq!(duration_at(250).source_position_ms, 250);
1625        assert_eq!(duration_at(251).phase, MediaPlaybackPhase::Stopped);
1626        assert_eq!(duration_at(251).source_position_ms, 250);
1627    }
1628
1629    #[test]
1630    fn click_triggered_media_uses_the_existing_timeline_click_count() {
1631        let timing = timing(
1632            r#"<p:set><p:cBhvr><p:cTn id="1" dur="1" nodeType="clickEffect"/><p:tgtEl><p:spTgt spid="8"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
1633            <p:audio><p:cMediaNode vol="100000"><p:cTn id="2" dur="indefinite"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cMediaNode></p:audio>
1634            <p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="3" dur="1" nodeType="clickEffect"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>"#,
1635        );
1636        let at = |click_count| {
1637            let media = evaluate_media_playback(
1638                Some(&timing),
1639                7,
1640                None,
1641                None,
1642                TimelinePosition {
1643                    elapsed_ms: 0,
1644                    click_count,
1645                },
1646            )
1647            .0;
1648            let page = evaluate_timeline(
1649                Some(&timing),
1650                None,
1651                TimelinePosition {
1652                    elapsed_ms: 0,
1653                    click_count,
1654                },
1655            )
1656            .unwrap();
1657            let visible = page.shapes.get(&8).is_none_or(|shape| shape.visible);
1658            (visible, media.phase)
1659        };
1660
1661        assert_eq!(at(0), (true, MediaPlaybackPhase::Stopped));
1662        assert_eq!(at(1), (false, MediaPlaybackPhase::Stopped));
1663        assert_eq!(at(2), (false, MediaPlaybackPhase::Playing));
1664    }
1665
1666    #[test]
1667    fn media_click_before_shape_click_consumes_the_first_shared_click_ordinal() {
1668        let timing = timing(
1669            r#"<p:audio><p:cMediaNode vol="100000"><p:cTn id="1" dur="indefinite"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cMediaNode></p:audio>
1670            <p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="2" dur="1" nodeType="clickEffect"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>
1671            <p:set><p:cBhvr><p:cTn id="3" dur="1" nodeType="clickEffect"/><p:tgtEl><p:spTgt spid="8"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>"#,
1672        );
1673        let at = |click_count| {
1674            let media = evaluate_media_playback(
1675                Some(&timing),
1676                7,
1677                None,
1678                None,
1679                TimelinePosition {
1680                    elapsed_ms: 0,
1681                    click_count,
1682                },
1683            )
1684            .0;
1685            let page = evaluate_timeline(
1686                Some(&timing),
1687                None,
1688                TimelinePosition {
1689                    elapsed_ms: 0,
1690                    click_count,
1691                },
1692            )
1693            .unwrap();
1694            let visible = page.shapes.get(&8).is_none_or(|shape| shape.visible);
1695            (visible, media.phase)
1696        };
1697
1698        assert_eq!(at(0), (true, MediaPlaybackPhase::Stopped));
1699        assert_eq!(at(1), (true, MediaPlaybackPhase::Playing));
1700        assert_eq!(at(2), (false, MediaPlaybackPhase::Playing));
1701    }
1702
1703    #[test]
1704    fn looping_without_a_known_duration_is_diagnostic_and_does_not_wrap() {
1705        let timing = timing(
1706            r#"<p:audio><p:cMediaNode vol="50000"><p:cTn id="1" dur="indefinite" repeatCount="indefinite"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cMediaNode></p:audio>
1707            <p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="2" dur="1"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>"#,
1708        );
1709        let (state, diagnostics) = evaluate_media_playback(
1710            Some(&timing),
1711            7,
1712            None,
1713            None,
1714            TimelinePosition {
1715                elapsed_ms: 500,
1716                click_count: 0,
1717            },
1718        );
1719
1720        assert_eq!(state.phase, MediaPlaybackPhase::Playing);
1721        assert_eq!(state.source_position_ms, 500);
1722        assert!(state.looping);
1723        assert_eq!(state.volume, 0.5);
1724        assert_eq!(diagnostics.len(), 1);
1725        assert!(
1726            diagnostics[0]
1727                .message
1728                .contains("no finite positive playback interval")
1729        );
1730    }
1731
1732    #[test]
1733    fn unsupported_media_triggers_diagnose_only_the_target_media_object() {
1734        let timing = timing(
1735            r#"<p:cmd type="call" cmd="play"><p:cBhvr><p:cTn id="1" dur="1"><p:stCondLst><p:cond evt="onPrev" delay="0"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:cmd>"#,
1736        );
1737        let (target, diagnostics) = evaluate_media_playback(
1738            Some(&timing),
1739            7,
1740            None,
1741            None,
1742            TimelinePosition {
1743                elapsed_ms: 500,
1744                click_count: 1,
1745            },
1746        );
1747        let (_, unrelated_diagnostics) = evaluate_media_playback(
1748            Some(&timing),
1749            8,
1750            None,
1751            None,
1752            TimelinePosition {
1753                elapsed_ms: 500,
1754                click_count: 1,
1755            },
1756        );
1757
1758        assert_eq!(target.phase, MediaPlaybackPhase::Stopped);
1759        assert_eq!(diagnostics.len(), 1);
1760        assert!(diagnostics[0].message.contains("media shape 7"));
1761        assert!(unrelated_diagnostics.is_empty());
1762    }
1763
1764    #[test]
1765    fn explicit_timestamps_evaluate_sequences_parallel_groups_and_clicks_deterministically() {
1766        let timing = timing(
1767            r#"<p:seq><p:cTn id="1" nodeType="mainSeq"><p:childTnLst>
1768            <p:set><p:cBhvr><p:cTn id="2" dur="100" fill="hold" nodeType="clickEffect"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="0"/></p:to></p:set>
1769            <p:anim from="0" to="100000"><p:cBhvr><p:cTn id="3" dur="200" fill="hold" nodeType="withEffect"/><p:tgtEl><p:spTgt spid="8"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1770            <p:set><p:cBhvr><p:cTn id="4" dur="100" fill="hold" nodeType="afterEffect"/><p:tgtEl><p:spTgt spid="9"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
1771            <p:par><p:cTn id="5" dur="100"><p:childTnLst>
1772            <p:anim from="0" to="100000"><p:cBhvr><p:cTn id="6" dur="100"/><p:tgtEl><p:spTgt spid="10"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1773            <p:anim from="100000" to="0"><p:cBhvr><p:cTn id="7" dur="100"/><p:tgtEl><p:spTgt spid="11"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1774            </p:childTnLst></p:cTn></p:par>
1775            </p:childTnLst></p:cTn></p:seq>
1776            <p:set><p:cBhvr><p:cTn id="8" dur="1" fill="hold"><p:stCondLst><p:cond delay="100"/><p:cond delay="200"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="12"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>"#,
1777        );
1778
1779        let before_click = evaluate_timeline(
1780            Some(&timing),
1781            None,
1782            TimelinePosition {
1783                elapsed_ms: 50,
1784                click_count: 0,
1785            },
1786        )
1787        .unwrap();
1788        let after_click = evaluate_timeline(
1789            Some(&timing),
1790            None,
1791            TimelinePosition {
1792                elapsed_ms: 150,
1793                click_count: 1,
1794            },
1795        )
1796        .unwrap();
1797        let after_previous = evaluate_timeline(
1798            Some(&timing),
1799            None,
1800            TimelinePosition {
1801                elapsed_ms: 250,
1802                click_count: 1,
1803            },
1804        )
1805        .unwrap();
1806        let parallel = evaluate_timeline(
1807            Some(&timing),
1808            None,
1809            TimelinePosition {
1810                elapsed_ms: 350,
1811                click_count: 1,
1812            },
1813        )
1814        .unwrap();
1815
1816        assert!(!before_click.shapes.contains_key(&7));
1817        assert_eq!(after_click.shapes[&7].opacity, 0.0);
1818        assert_eq!(after_click.shapes[&8].opacity, 0.75);
1819        assert!(!after_click.shapes.contains_key(&9));
1820        assert!(!after_click.shapes[&12].visible);
1821        assert!(!after_previous.shapes[&9].visible);
1822        assert_eq!(parallel.shapes[&10].opacity, 0.5);
1823        assert_eq!(parallel.shapes[&11].opacity, 0.5);
1824    }
1825
1826    #[test]
1827    fn start_condition_alternatives_select_the_first_available_trigger() {
1828        let timing = timing(
1829            r#"<p:set><p:cBhvr><p:cTn id="1" dur="1" fill="hold"><p:stCondLst><p:cond delay="100"/><p:cond evt="onClick" delay="10"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>"#,
1830        );
1831
1832        let timed = evaluate_timeline(
1833            Some(&timing),
1834            None,
1835            TimelinePosition {
1836                elapsed_ms: 150,
1837                click_count: 0,
1838            },
1839        )
1840        .unwrap();
1841        let clicked = evaluate_timeline(
1842            Some(&timing),
1843            None,
1844            TimelinePosition {
1845                elapsed_ms: 50,
1846                click_count: 1,
1847            },
1848        )
1849        .unwrap();
1850
1851        assert!(!timed.shapes[&7].visible);
1852        assert!(!clicked.shapes[&7].visible);
1853    }
1854
1855    #[test]
1856    fn end_conditions_bound_intervals_and_indefinite_leaves_block_sequences() {
1857        let timing = timing(
1858            r#"<p:seq><p:cTn id="1"><p:childTnLst>
1859            <p:anim from="0" to="100000"><p:cBhvr><p:cTn id="2" dur="indefinite" fill="hold"><p:endCondLst><p:cond delay="200"/></p:endCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1860            <p:set><p:cBhvr><p:cTn id="3" dur="1" fill="hold"/><p:tgtEl><p:spTgt spid="8"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
1861            </p:childTnLst></p:cTn></p:seq>
1862            <p:seq><p:cTn id="4"><p:childTnLst>
1863            <p:set><p:cBhvr><p:cTn id="5" dur="indefinite" fill="hold"/><p:tgtEl><p:spTgt spid="9"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
1864            <p:set><p:cBhvr><p:cTn id="6" dur="1" fill="hold"/><p:tgtEl><p:spTgt spid="10"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
1865            </p:childTnLst></p:cTn></p:seq>
1866            <p:anim from="0" to="100000"><p:cBhvr><p:cTn id="7" dur="1000"><p:endCondLst><p:cond delay="100"><p:rtn val="all"/></p:cond></p:endCondLst></p:cTn><p:tgtEl><p:spTgt spid="11"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>"#,
1867        );
1868
1869        let before_end = evaluate_timeline(
1870            Some(&timing),
1871            None,
1872            TimelinePosition {
1873                elapsed_ms: 150,
1874                click_count: 0,
1875            },
1876        )
1877        .unwrap();
1878        let after_end = evaluate_timeline(
1879            Some(&timing),
1880            None,
1881            TimelinePosition {
1882                elapsed_ms: 250,
1883                click_count: 0,
1884            },
1885        )
1886        .unwrap();
1887
1888        assert_eq!(before_end.shapes[&7].opacity, 0.75);
1889        assert!(!before_end.shapes.contains_key(&8));
1890        assert!(!after_end.shapes[&8].visible);
1891        assert!(!after_end.shapes[&9].visible);
1892        assert!(!after_end.shapes.contains_key(&10));
1893        assert!(after_end.diagnostics.iter().any(|diagnostic| {
1894            diagnostic
1895                .message
1896                .contains("unsupported timing condition trigger")
1897        }));
1898    }
1899
1900    #[test]
1901    fn container_end_boundaries_stop_parallel_and_sequence_children() {
1902        let timing = timing(
1903            r#"<p:par><p:cTn id="1" dur="200"><p:childTnLst>
1904            <p:anim from="0" to="100000"><p:cBhvr><p:cTn id="2" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1905            </p:childTnLst></p:cTn></p:par>
1906            <p:seq><p:cTn id="3" dur="200"><p:childTnLst>
1907            <p:anim from="0" to="100000"><p:cBhvr><p:cTn id="4" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="8"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1908            <p:set><p:cBhvr><p:cTn id="5" dur="1" fill="hold"/><p:tgtEl><p:spTgt spid="9"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
1909            </p:childTnLst></p:cTn></p:seq>"#,
1910        );
1911
1912        let active = evaluate_timeline(
1913            Some(&timing),
1914            None,
1915            TimelinePosition {
1916                elapsed_ms: 150,
1917                click_count: 0,
1918            },
1919        )
1920        .unwrap();
1921        let ended = evaluate_timeline(
1922            Some(&timing),
1923            None,
1924            TimelinePosition {
1925                elapsed_ms: 500,
1926                click_count: 0,
1927            },
1928        )
1929        .unwrap();
1930
1931        assert_eq!(active.shapes[&7].opacity, 0.15);
1932        assert_eq!(active.shapes[&8].opacity, 0.15);
1933        assert!(!active.shapes.contains_key(&9));
1934        assert!(!ended.shapes.contains_key(&7));
1935        assert!(!ended.shapes.contains_key(&8));
1936        assert!(!ended.shapes.contains_key(&9));
1937    }
1938
1939    #[test]
1940    fn entrance_exit_emphasis_and_motion_states_clamp_at_boundaries() {
1941        let timing = timing(
1942            r#"<p:par><p:cTn id="1"><p:childTnLst>
1943            <p:animEffect transition="in" filter="fade"><p:cBhvr><p:cTn id="2" dur="1000" fill="hold"><p:stCondLst><p:cond delay="100"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:animEffect>
1944            <p:animMotion origin="layout" path="M 0 0 L .5 0 L .5 1 E"><p:cBhvr><p:cTn id="3" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="8"/></p:tgtEl></p:cBhvr></p:animMotion>
1945            <p:animEffect transition="out" filter="fade"><p:cBhvr><p:cTn id="4" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="9"/></p:tgtEl></p:cBhvr></p:animEffect>
1946            <p:animEffect transition="in" filter="appear"><p:cBhvr><p:cTn id="5" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="10"/></p:tgtEl></p:cBhvr></p:animEffect>
1947            <p:anim from="100000" to="200000"><p:cBhvr><p:cTn id="6" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="11"/></p:tgtEl><p:attrNameLst><p:attrName>scale</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1948            <p:anim from="0" to="5400000"><p:cBhvr><p:cTn id="7" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="11"/></p:tgtEl><p:attrNameLst><p:attrName>spin</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1949            <p:animEffect transition="in" filter="wipe(left)"><p:cBhvr><p:cTn id="8" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="12"/></p:tgtEl></p:cBhvr></p:animEffect>
1950            <p:set><p:cBhvr><p:cTn id="9" dur="100" fill="remove"/><p:tgtEl><p:spTgt spid="13"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
1951            <p:animEffect transition="out" filter="wipe(down)"><p:cBhvr><p:cTn id="10" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="14"/></p:tgtEl></p:cBhvr></p:animEffect>
1952            <p:animEffect transition="out" filter="appear"><p:cBhvr><p:cTn id="11" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="15"/></p:tgtEl></p:cBhvr></p:animEffect>
1953            <p:anim from="0" to="NaN"><p:cBhvr><p:cTn id="12" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="16"/></p:tgtEl><p:attrNameLst><p:attrName>style.opacity</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
1954            </p:childTnLst></p:cTn></p:par>"#,
1955        );
1956        let before_start = evaluate_timeline(
1957            Some(&timing),
1958            None,
1959            TimelinePosition {
1960                elapsed_ms: 99,
1961                click_count: 0,
1962            },
1963        )
1964        .unwrap();
1965        let at_start = evaluate_timeline(
1966            Some(&timing),
1967            None,
1968            TimelinePosition {
1969                elapsed_ms: 100,
1970                click_count: 0,
1971            },
1972        )
1973        .unwrap();
1974        let at_zero = evaluate_timeline(
1975            Some(&timing),
1976            None,
1977            TimelinePosition {
1978                elapsed_ms: 0,
1979                click_count: 0,
1980            },
1981        )
1982        .unwrap();
1983        let at_end = evaluate_timeline(
1984            Some(&timing),
1985            None,
1986            TimelinePosition {
1987                elapsed_ms: 1000,
1988                click_count: 0,
1989            },
1990        )
1991        .unwrap();
1992        let after_end = evaluate_timeline(
1993            Some(&timing),
1994            None,
1995            TimelinePosition {
1996                elapsed_ms: 1101,
1997                click_count: 0,
1998            },
1999        )
2000        .unwrap();
2001        let delayed_end = evaluate_timeline(
2002            Some(&timing),
2003            None,
2004            TimelinePosition {
2005                elapsed_ms: 1100,
2006                click_count: 0,
2007            },
2008        )
2009        .unwrap();
2010
2011        assert!(!before_start.shapes[&7].visible);
2012        assert!(at_start.shapes[&7].visible);
2013        assert_eq!(at_start.shapes[&7].opacity, 0.0);
2014        assert_eq!(at_end.shapes[&7].opacity, 0.9);
2015        assert_eq!(delayed_end.shapes[&7].opacity, 1.0);
2016        assert_eq!(after_end.shapes[&7].opacity, 1.0);
2017        assert_eq!(at_end.shapes[&8].transform.e, 0.5);
2018        assert_eq!(at_end.shapes[&8].transform.f, 1.0);
2019        assert!(!at_end.shapes[&9].visible);
2020        assert_eq!(at_zero.shapes[&10].opacity, 1.0);
2021        assert!(!at_zero.shapes[&15].visible);
2022        let centre = at_end.shapes[&11].transform.apply(Point { x: 0.5, y: 0.5 });
2023        assert!((centre.x - 0.5).abs() < 1.0e-10);
2024        assert!((centre.y - 0.5).abs() < 1.0e-10);
2025        assert_eq!(at_start.shapes[&12].clip.unwrap().x, 0.9);
2026        assert_eq!(at_end.shapes[&12].clip.unwrap().width, 1.0);
2027        assert!(!at_end.shapes.contains_key(&13));
2028        assert_eq!(at_end.shapes[&14].clip.unwrap().height, 0.0);
2029        assert!(!at_start.shapes[&15].visible);
2030        assert!(!at_end.shapes.contains_key(&16));
2031        assert!(
2032            at_end
2033                .diagnostics
2034                .iter()
2035                .any(|diagnostic| diagnostic.message.contains("non-finite"))
2036        );
2037    }
2038
2039    #[test]
2040    fn unsupported_timing_nodes_do_not_hide_supported_siblings() {
2041        let timing = timing(
2042            r#"<p:par><p:cTn id="1"><p:childTnLst><p:cmd type="call" cmd="x"/><p:set><p:cBhvr><p:cTn id="2" dur="1" fill="hold"/><p:tgtEl><p:spTgt spid="9"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set></p:childTnLst></p:cTn></p:par>"#,
2043        );
2044        let state = evaluate_timeline(
2045            Some(&timing),
2046            None,
2047            TimelinePosition {
2048                elapsed_ms: 1,
2049                click_count: 0,
2050            },
2051        )
2052        .unwrap();
2053
2054        assert!(!state.shapes[&9].visible);
2055        assert_eq!(state.diagnostics.len(), 1);
2056    }
2057
2058    #[test]
2059    fn relative_motion_commands_accumulate_from_the_current_point() {
2060        let timing = timing(
2061            r#"<p:animMotion origin="parent" path="M 0 0 l .2 .1 l .2 .1 E"><p:cBhvr><p:cTn id="1" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl></p:cBhvr></p:animMotion>"#,
2062        );
2063        let state = evaluate_timeline(
2064            Some(&timing),
2065            None,
2066            TimelinePosition {
2067                elapsed_ms: 1000,
2068                click_count: 0,
2069            },
2070        )
2071        .unwrap();
2072
2073        assert_eq!(state.shapes[&7].transform.e, 0.4);
2074        assert_eq!(state.shapes[&7].transform.f, 0.2);
2075    }
2076
2077    #[test]
2078    fn unsupported_semantics_and_overflow_are_diagnostic_and_finite() {
2079        let timing = timing(
2080            r#"<p:anim from="0" to="100000"><p:cBhvr><p:cTn id="1" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="7"/></p:tgtEl><p:attrNameLst><p:attrName>ppt_x</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
2081            <p:anim from="-1.7976931348623157e308" to="1.7976931348623157e308"><p:cBhvr><p:cTn id="2" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="8"/></p:tgtEl><p:attrNameLst><p:attrName>scale</p:attrName></p:attrNameLst></p:cBhvr></p:anim>
2082            <p:animEffect transition="none" filter="fade"><p:cBhvr><p:cTn id="3" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="9"/></p:tgtEl></p:cBhvr></p:animEffect>
2083            <p:set><p:cBhvr><p:cTn id="4" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"><p:rtn val="all"/></p:cond></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="10"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
2084            <p:set><p:cBhvr><p:cTn id="5" dur="1" fill="hold"><p:stCondLst><p:cond delay="0"/></p:stCondLst></p:cTn><p:tgtEl><p:spTgt spid="11"/></p:tgtEl><p:attrNameLst><p:attrName>style.visibility</p:attrName></p:attrNameLst></p:cBhvr><p:to><p:strVal val="hidden"/></p:to></p:set>
2085            <p:animEffect transition="in" filter="producer-fade-token"><p:cBhvr><p:cTn id="6" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="12"/></p:tgtEl></p:cBhvr></p:animEffect>
2086            <p:animEffect transition="in" filter="wipe(diagonal)"><p:cBhvr><p:cTn id="7" dur="1000" fill="hold"/><p:tgtEl><p:spTgt spid="13"/></p:tgtEl></p:cBhvr></p:animEffect>"#,
2087        );
2088        let state = evaluate_timeline(
2089            Some(&timing),
2090            None,
2091            TimelinePosition {
2092                elapsed_ms: 1000,
2093                click_count: 0,
2094            },
2095        )
2096        .unwrap();
2097
2098        assert!(!state.shapes.contains_key(&7));
2099        assert!(!state.shapes.contains_key(&8));
2100        assert!(!state.shapes.contains_key(&9));
2101        assert!(!state.shapes.contains_key(&10));
2102        assert!(!state.shapes[&11].visible);
2103        assert!(!state.shapes.contains_key(&12));
2104        assert!(!state.shapes.contains_key(&13));
2105        assert!(
2106            state
2107                .shapes
2108                .values()
2109                .all(super::EvaluatedShapeState::is_finite)
2110        );
2111        assert!(
2112            state
2113                .diagnostics
2114                .iter()
2115                .any(|diagnostic| diagnostic.message.contains("ppt_x"))
2116        );
2117        assert!(
2118            state
2119                .diagnostics
2120                .iter()
2121                .any(|diagnostic| diagnostic.message.contains("arithmetic"))
2122        );
2123        assert!(state.diagnostics.iter().any(|diagnostic| {
2124            diagnostic
2125                .message
2126                .contains("unsupported timing condition trigger")
2127        }));
2128        assert!(state.diagnostics.iter().any(|diagnostic| {
2129            diagnostic
2130                .message
2131                .contains("unsupported timing effect transition none")
2132        }));
2133        assert!(state.diagnostics.iter().any(|diagnostic| {
2134            diagnostic
2135                .message
2136                .contains("unsupported timing effect producer-fade-token")
2137        }));
2138        assert!(state.diagnostics.iter().any(|diagnostic| {
2139            diagnostic
2140                .message
2141                .contains("unsupported timing effect wipe(diagonal)")
2142        }));
2143    }
2144}