motion_core/authoring/context.rs
1//! Stateless authoring time, design coordinates and optional shared computation.
2
3use std::hash::Hash;
4use std::sync::Arc;
5
6use crate::{Animation, Duration, FrameRate, Node, Point, Random, Rect, Size, Time};
7
8mod cache;
9mod partition;
10#[cfg(test)]
11mod tests;
12
13pub use cache::SceneCache;
14pub use partition::{CacheLimits, CachePartition, RenderCache};
15
16/// Immutable scene evaluation inputs in signed ticks and logical coordinates.
17///
18/// The caller supplies time explicitly and can reuse one [`SceneCache`] across
19/// arbitrary frame orders and threads, without initializing rendering resources
20/// or interpreting design dimensions as output pixels.
21///
22/// ```
23/// use motion_core::prelude::*;
24/// use motion_core::{
25/// DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
26/// MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
27/// };
28/// use std::sync::Arc;
29///
30/// let cache = SceneCache::new(32);
31/// let tween = Tween::new(0.0, 100.0, 1.seconds());
32/// let evaluate = |time| {
33/// let context = SceneContext::new(time, FrameRate::from(30), Size::new(640.0, 360.0), &cache)
34/// .with_clip_duration(2.seconds());
35/// let positions = context.cache(("dots", 8_u32), || {
36/// (0..8_u32)
37/// .map(|index| Point::new(context.random(("x", index)) * 640.0, 180.0))
38/// .collect::<Vec<_>>()
39/// });
40/// let local = context.shifted(500.milliseconds());
41/// assert_eq!(local.global_time(), time);
42/// (local.sample(&tween), positions)
43/// };
44/// let (late, first) = evaluate(1500.milliseconds());
45/// let (early, second) = evaluate(500.milliseconds());
46/// let (again, third) = evaluate(1500.milliseconds());
47/// assert_eq!((late, early, again), (100.0, 0.0, 100.0));
48/// assert!(Arc::ptr_eq(&first, &second) && Arc::ptr_eq(&first, &third));
49/// ```
50#[derive(Clone, Copy)]
51pub struct SceneContext<'a> {
52 quality: crate::RenderQuality,
53 time: Time,
54 global_time: Time,
55 clip_duration: Option<Duration>,
56 frame_rate: FrameRate,
57 size: Size,
58 nested_scene: bool,
59
60 assets: Option<&'a crate::Assets>,
61
62 cache: &'a SceneCache,
63
64 diagnostics: Option<&'a crate::RequestDiagnostics>,
65 sounds: Option<&'a std::sync::Mutex<Vec<crate::AudioClip>>>,
66}
67
68impl<'a> SceneContext<'a> {
69 pub(crate) fn collecting_sounds(
70 mut self,
71 sounds: &'a std::sync::Mutex<Vec<crate::AudioClip>>,
72 ) -> Self {
73 self.sounds = Some(sounds);
74 self
75 }
76
77 /// Declares local audio in a `scene_with_sound` metadata pass; visual snapshots never play sound.
78 /// Declare outside time-dependent branches so arbitrary frame order cannot change the track.
79 pub fn sound(&self, clip: crate::AudioClip) -> crate::Result {
80 if let Some(sounds) = self.sounds {
81 let mut sounds = sounds.lock().map_err(|_| {
82 crate::ValidationError::invalid(
83 "scene_audio",
84 "lock",
85 "Sound declaration lock is poisoned",
86 )
87 })?;
88 if sounds.len() >= 256 {
89 return Err(crate::ValidationError::invalid(
90 "scene_audio",
91 "count",
92 "At most 256 local sound declarations",
93 )
94 .into());
95 }
96 if let Some(clip) = clip.relocated(
97 self.global_time - self.time,
98 self.clip_duration.unwrap_or(Time::from_seconds(86400.0)),
99 )? {
100 sounds.push(clip);
101 }
102 }
103 Ok(())
104 }
105
106 /// Creates root evaluation inputs with matching local/global time.
107 ///
108 /// Panics unless both design dimensions are finite and strictly positive.
109 pub fn new(time: Time, frame_rate: FrameRate, size: Size, cache: &'a SceneCache) -> Self {
110 Self::try_new(time, frame_rate, size, cache)
111 .unwrap_or_else(|error| panic!("{}", error.message))
112 }
113
114 /// Creates evaluation inputs with recoverable design-size validation.
115 pub fn try_new(
116 time: Time,
117 frame_rate: FrameRate,
118 size: Size,
119 cache: &'a SceneCache,
120 ) -> Result<Self, crate::ValidationError> {
121 if !size.width.is_finite()
122 || size.width <= 0.0
123 || !size.height.is_finite()
124 || size.height <= 0.0
125 {
126 return Err(crate::ValidationError::invalid(
127 "scene_context",
128 "size",
129 "scene design dimensions must be finite and positive",
130 ));
131 }
132 Ok(Self {
133 quality: crate::RenderQuality::Final,
134 time,
135 global_time: time,
136 clip_duration: None,
137 frame_rate,
138 size,
139 nested_scene: false,
140 assets: None,
141 cache,
142 diagnostics: None,
143 sounds: None,
144 })
145 }
146
147 /// Selects preview approximations, preserved by nested and sampled contexts.
148 pub fn quality(mut self, quality: crate::RenderQuality) -> Self {
149 self.quality = quality;
150 self
151 }
152
153 /// Explicit rendering quality; manually constructed contexts default to Final.
154 pub fn render_quality(&self) -> crate::RenderQuality {
155 self.quality
156 }
157
158 /// Always available resources; an unconfigured context uses an empty owner.
159 pub fn assets(&self) -> &'a crate::Assets {
160 self.assets.unwrap_or(&self.cache.empty_assets)
161 }
162
163 /// Attaches the common resource owner without reading or decoding.
164 pub fn with_assets(mut self, assets: &'a crate::Assets) -> Self {
165 self.assets = Some(assets);
166 self
167 }
168
169 /// Attaches a request-owned sink suitable for explicit background resource reads.
170 pub fn with_diagnostics(mut self, diagnostics: &'a crate::RequestDiagnostics) -> Self {
171 self.diagnostics = Some(diagnostics);
172 self
173 }
174
175 /// Diagnostic scope retained by shifted, sampled and nested contexts.
176 pub fn diagnostics(&self) -> Option<&'a crate::RequestDiagnostics> {
177 self.diagnostics
178 }
179
180 /// Sets finite clip metadata; zero denotes an instantaneous clip.
181 ///
182 /// Panics for negative duration. Sampling time is not clamped to the clip.
183 pub fn with_clip_duration(mut self, duration: Duration) -> Self {
184 assert!(duration >= Time::ZERO, "clip duration must be nonnegative");
185 self.clip_duration = Some(duration);
186 self
187 }
188
189 /// Sets clip metadata with a recoverable negative-duration diagnostic.
190 pub fn try_with_clip_duration(
191 mut self,
192 duration: Duration,
193 ) -> Result<Self, crate::ValidationError> {
194 if duration < Time::ZERO {
195 return Err(crate::ValidationError::invalid(
196 "scene_context",
197 "clip_duration",
198 "clip duration must be nonnegative",
199 ));
200 }
201 self.clip_duration = Some(duration);
202 Ok(self)
203 }
204
205 /// Signed clip-local time.
206 pub fn time(&self) -> Time {
207 self.time
208 }
209
210 /// Clip-local time in floating-point seconds, for animation mathematics.
211 pub fn seconds(&self) -> f64 {
212 self.time.as_seconds()
213 }
214
215 /// Root evaluation time, unaffected by local shifts.
216 pub fn global_time(&self) -> Time {
217 self.global_time
218 }
219
220 /// Explicit clip duration, absent for an unconstrained scene.
221 pub fn clip_duration(&self) -> Option<Duration> {
222 self.clip_duration
223 }
224
225 /// Clamped local progress; unconstrained scenes return zero.
226 ///
227 /// A zero-duration clip jumps from zero to one at local time zero.
228 pub fn progress(&self) -> f64 {
229 self.clip_duration
230 .map_or(0.0, |duration| self.progress_in(Time::ZERO, duration))
231 }
232
233 /// Clamped progress in a local interval, using integer boundary comparisons.
234 ///
235 /// Zero duration jumps at start. Panics for negative duration. Wide integer
236 /// differences allow queries across opposite signed tick extremes.
237 pub fn progress_in(&self, start: Time, duration: Duration) -> f64 {
238 assert!(
239 duration >= Time::ZERO,
240 "interval duration must be nonnegative"
241 );
242 let elapsed = i128::from(self.time.ticks()) - i128::from(start.ticks());
243 if elapsed < 0 {
244 0.0
245 } else if elapsed >= i128::from(duration.ticks()) {
246 1.0
247 } else {
248 elapsed as f64 / duration.ticks() as f64
249 }
250 }
251
252 /// Eased local interval progress, with the same clamping as progress_in.
253 pub fn ease_in(&self, start: Duration, duration: Duration, easing: crate::Easing) -> f64 {
254 easing.apply(self.progress_in(start, duration))
255 }
256
257 /// Exact rational authoring frame rate shared by still, preview and export clocks.
258 pub fn frame_rate(&self) -> FrameRate {
259 self.frame_rate
260 }
261
262 pub(crate) fn for_timeline(&self, size: Size, frame_rate: FrameRate) -> Self {
263 Self {
264 frame_rate,
265 size,
266 nested_scene: true,
267 ..*self
268 }
269 }
270
271 pub(crate) fn is_nested_scene(&self) -> bool {
272 self.nested_scene
273 }
274
275 /// Converts a signed frame count directly to ticks without cumulative rounding.
276 pub fn frames(&self, count: i64) -> Duration {
277 Time::from_frame(count, self.frame_rate)
278 }
279
280 /// Samples an animation using local, not global, time.
281 pub fn sample<Value>(&self, animation: &(impl Animation<Value> + ?Sized)) -> Value {
282 animation.sample(self.time)
283 }
284
285 /// Subtracts an offset from local time, preserving global time and clip metadata.
286 ///
287 /// Offsets can be negative. Panics if the resulting local ticks overflow.
288 pub fn shifted(&self, offset: Duration) -> Self {
289 Self {
290 time: self.time - offset,
291 ..*self
292 }
293 }
294
295 /// Subtracts a local offset without panicking on tick overflow.
296 pub fn try_shifted(&self, offset: Duration) -> Result<Self, crate::ValidationError> {
297 let ticks = self
298 .time
299 .ticks()
300 .checked_sub(offset.ticks())
301 .ok_or_else(|| {
302 crate::ValidationError::invalid(
303 "scene_context",
304 "time",
305 "Shifted local ticks overflow",
306 )
307 })?;
308 Ok(Self {
309 time: Time::from_ticks(ticks),
310 ..*self
311 })
312 }
313
314 pub(crate) fn resampled(&self, offset: Duration) -> Result<Self, crate::ValidationError> {
315 let shift = |time: Time| {
316 time.ticks()
317 .checked_add(offset.ticks())
318 .map(Time::from_ticks)
319 .ok_or_else(|| {
320 crate::ValidationError::invalid(
321 "motion_blur",
322 "time",
323 "Subframe local/global ticks overflow",
324 )
325 })
326 };
327 Ok(Self {
328 time: shift(self.time)?,
329 global_time: shift(self.global_time)?,
330 ..*self
331 })
332 }
333
334 /// Resamples a local subtree at 1..=64 centered integer shutter midpoints.
335 /// Both local/global clocks move; clip metadata, resources and cache persist.
336 /// Signed samples outside a clip are supplied unchanged for caller-owned gating.
337 pub fn try_motion_blur(
338 &self,
339 shutter: Duration,
340 samples: u32,
341 build: impl FnMut(&SceneContext<'_>) -> Node,
342 ) -> Result<Node, crate::ValidationError> {
343 crate::authoring::sampling::capture(self, shutter, samples, build)
344 }
345
346 /// Captures shutter samples, deferring invalid sampling parameters to compilation.
347 pub fn motion_blur(
348 &self,
349 shutter: Duration,
350 samples: u32,
351 build: impl FnMut(&SceneContext<'_>) -> Node,
352 ) -> Node {
353 self.try_motion_blur(shutter, samples, build)
354 .unwrap_or_else(super::scene::failure)
355 }
356
357 /// Builds only in [start, start + duration), with shifted local clip inputs.
358 ///
359 /// Outside the interval, including zero duration, returns an empty node and
360 /// does not call build. Global time stays unchanged. Integer differences avoid
361 /// overflowing the endpoint sum. Panics for negative duration.
362 pub fn during(
363 &self,
364 start: Time,
365 duration: Duration,
366 build: impl FnOnce(&SceneContext<'a>) -> Node,
367 ) -> Node {
368 assert!(
369 duration >= Time::ZERO,
370 "interval duration must be nonnegative"
371 );
372 let elapsed = i128::from(self.time.ticks()) - i128::from(start.ticks());
373 if elapsed < 0 || elapsed >= i128::from(duration.ticks()) {
374 Node::empty()
375 } else {
376 build(&Self {
377 nested_scene: true,
378 ..self.shifted(start).with_clip_duration(duration)
379 })
380 .with_scene_time(Time::from_ticks(elapsed as i64))
381 }
382 }
383
384 /// Evaluates a child scene starting at a parent-local offset.
385 ///
386 /// Finite children use their half-open duration. Infinite children are active
387 /// from offset onward and clear inherited clip-duration metadata. The parent
388 /// context itself is never clamped; its caller controls the parent interval.
389 /// Panics for invalid child duration or unrepresentable shifted local ticks.
390 /// Timeline children clip and contain their own design viewport;
391 /// changing the child frame grid never quantizes the elapsed parent ticks.
392 pub fn embed(&self, scene: &dyn crate::authoring::Scene, offset: Duration) -> Node {
393 match scene.duration() {
394 Some(duration) => self.during(offset, duration, |context| scene.render(context)),
395 None if self.time < offset => Node::empty(),
396 None => scene.render(&Self {
397 clip_duration: None,
398 nested_scene: true,
399 ..self.shifted(offset)
400 }),
401 }
402 }
403
404 /// Logical design dimensions, not device pixels.
405 pub fn size(&self) -> Size {
406 self.size
407 }
408
409 /// Opposite-corner logical bounds at the origin.
410 pub fn bounds(&self) -> Rect {
411 self.size.to_rect()
412 }
413
414 /// Center of the logical design bounds.
415 pub fn center(&self) -> Point {
416 self.bounds().center()
417 }
418
419 /// Measures content or declared layout in parent coordinates, independently of time.
420 ///
421 /// Uses default structural limits without rendering or f32 pixel conversion.
422 /// For explicit budgets use [`Node::measure`]. Leaves supply their own bounds
423 /// with the shared explicit font environment. This does not invoke drawing
424 /// or infer filter extents; command validation occurs during compilation.
425 pub fn measure(&self, node: &Node) -> Result<Rect, crate::ValidationError> {
426 let drawing = crate::DrawContext::default().with_assets(self.assets());
427 node.measure_with(&drawing, crate::ValidationLimits::default())
428 }
429
430 /// Measures like [`measure`](Self::measure) but substitutes `fallback` when measurement fails,
431 /// for example `context.measure_or(&label, Rect::default())`. The failure still surfaces
432 /// when the node is compiled.
433 pub fn measure_or(&self, node: &Node, fallback: Rect) -> Rect {
434 self.measure(node).unwrap_or(fallback)
435 }
436
437 /// Places a measured snapshot next to a reference in parent coordinates.
438 ///
439 /// Vertical sides center horizontally; horizontal sides center vertically.
440 /// Finite negative gaps intentionally overlap. Returns errors for unknown
441 /// geometry, invalid gaps or unrepresentable offsets; inputs stay unchanged.
442 /// This is immediate snapshot positioning, not a live constraint to reference.
443 ///
444 /// ```
445 /// use motion_core::prelude::*;
446 /// use motion_core::{
447 /// DisplayList, RasterLimits, ResourcePool, ValidatedDisplayList, MAX_PATH_VERBS,
448 /// MAX_STROKE_VERBS, MAX_SVG_PATH_BYTES, sample_offsets,
449 /// };
450 /// let cache = SceneCache::new(0);
451 /// let context = SceneContext::new(
452 /// Time::ZERO,
453 /// FrameRate::from(30),
454 /// Size::new(200.0, 100.0),
455 /// &cache,
456 /// );
457 /// let reference = Node::from(Rect::new(10.0, 10.0, 50.0, 30.0).fill(Color::WHITE));
458 /// let card = Node::from(Rect::new(0.0, 0.0, 8.0, 4.0).fill(Color::WHITE));
459 /// let card = context.next_to(card, &reference, Side::Right, 6.0);
460 /// assert_eq!(context.measure(&card)?, Rect::new(56.0, 18.0, 64.0, 22.0));
461 /// let card = context.align_to(card, &reference, Side::Bottom);
462 /// assert_eq!(context.measure(&card)?, Rect::new(56.0, 26.0, 64.0, 30.0));
463 /// # Ok::<(), motion_core::ValidationError>(())
464 /// ```
465 pub fn try_next_to(
466 &self,
467 node: impl Into<Node>,
468 reference: &Node,
469 side: crate::authoring::Side,
470 gap: f64,
471 ) -> Result<Node, crate::ValidationError> {
472 if !gap.is_finite() {
473 return Err(crate::ValidationError::invalid(
474 "layout",
475 "gap",
476 "Relative layout gap must be finite",
477 ));
478 }
479 let node = node.into();
480 let offset = side.adjacent(self.measure(&node)?, self.measure(reference)?, gap);
481 self.translated_layout(node, offset)
482 }
483
484 /// Aligns one measured edge with a reference, preserving the other axis.
485 ///
486 /// Uses the already transformed parent-space AABBs. Existing position,
487 /// rotation, scale and pivot remain intact; only translation is added.
488 pub fn try_align_to(
489 &self,
490 node: impl Into<Node>,
491 reference: &Node,
492 edge: crate::authoring::Side,
493 ) -> Result<Node, crate::ValidationError> {
494 let node = node.into();
495 let offset = edge.aligned(self.measure(&node)?, self.measure(reference)?);
496 self.translated_layout(node, offset)
497 }
498
499 /// Measures and places the snapshot next to its reference during compilation.
500 pub fn next_to(
501 &self,
502 node: impl Into<Node>,
503 reference: &Node,
504 side: super::Side,
505 gap: f64,
506 ) -> Node {
507 node.into().next_to(reference, side, gap)
508 }
509
510 /// Aligns one edge using the current compile-time font and drawing environment.
511 pub fn align_to(&self, node: impl Into<Node>, reference: &Node, edge: super::Side) -> Node {
512 node.into().align_to(reference, edge)
513 }
514
515 fn translated_layout(
516 &self,
517 node: Node,
518 offset: crate::Vector2,
519 ) -> Result<Node, crate::ValidationError> {
520 use crate::NodeModifiers;
521 if !offset.x.is_finite() || !offset.y.is_finite() {
522 return Err(crate::ValidationError::invalid(
523 "layout",
524 "translation",
525 "Relative layout offset must remain finite",
526 ));
527 }
528 let node = node.translate(offset.x, offset.y);
529 self.measure(&node)?;
530 Ok(node)
531 }
532
533 /// Returns a key-derived value in [0, 1), independent of time and cache state.
534 pub fn random(&self, key: impl Hash) -> f64 {
535 self.random_generator(key).next_float()
536 }
537
538 /// Starts a fresh key-derived stream, independent of all other evaluations.
539 pub fn random_generator(&self, key: impl Hash) -> Random {
540 Random::from_key(key)
541 }
542
543 /// Reuses a pure value by calling location, value type and an equal typed key.
544 ///
545 /// Keys must distinguish all inputs, including resource revisions. Keys are
546 /// retained, so use owned data or static references rather than borrowed locals.
547 /// Hash collisions are resolved by key type and equality.
548 /// Builders may be rerun after eviction, clear, disabled caching or races.
549 #[track_caller]
550 pub fn cache<Key: Eq + Hash + Send + Sync + 'static, Value: Send + Sync + 'static>(
551 &self,
552 key: Key,
553 build: impl FnOnce() -> Value,
554 ) -> Arc<Value> {
555 self.cache.get(key, build)
556 }
557
558 /// Reuses a pure static subtree without exposing its shared Arc handle.
559 #[track_caller]
560 pub fn memo<Key: Eq + Hash + Send + Sync + 'static, Output: Into<Node>>(
561 &self,
562 key: Key,
563 build: impl FnOnce() -> Output,
564 ) -> Node {
565 self.cache(key, || build().into()).as_ref().clone()
566 }
567
568 /// Caches a pure value with an explicit logical retained-byte estimate.
569 /// Cost includes the key, value and their owned heap payloads, with a minimum
570 /// of their combined inline sizes. Entry metadata, allocator overhead and
571 /// shared external pins are excluded.
572 /// Oversized values are returned without retention; cost runs outside the lock.
573 /// Keys retain the same equality-checked, callsite-scoped contract as cache().
574 #[track_caller]
575 pub fn cache_with_cost<Key: Eq + Hash + Send + Sync + 'static, Value: Send + Sync + 'static>(
576 &self,
577 key: Key,
578 build: impl FnOnce() -> Value,
579 cost: impl FnOnce(&Value) -> usize,
580 ) -> Arc<Value> {
581 self.cache.get_with_cost(key, build, cost)
582 }
583}