///|
/// A resolved position on an animation timeline.
pub(all) struct TimelinePosition {
index : Int
elapsed_cs : Int
remaining_cs : Int
} derive(Eq, Debug)
///|
pub fn TimelinePosition::index(self : TimelinePosition) -> Int {
self.index
}
///|
pub fn TimelinePosition::elapsed_cs(self : TimelinePosition) -> Int {
self.elapsed_cs
}
///|
pub fn TimelinePosition::remaining_cs(self : TimelinePosition) -> Int {
self.remaining_cs
}
///|
/// Validate dimensions, offsets, delays, and GIF representability.
pub fn Animation::validate(self : Animation) -> Array[String] {
let issues : Array[String] = []
if self.width <= 0 || self.height <= 0 {
issues.push("logical screen must have positive dimensions")
}
if self.width > 65535 || self.height > 65535 {
issues.push("logical screen exceeds GIF's 16-bit dimension limit")
}
if self.loop_count is Some(value) && (value < 0 || value > 65535) {
issues.push("loop count must be between 0 and 65535")
}
for index, frame in self.frames {
if frame.canvas.width <= 0 || frame.canvas.height <= 0 {
issues.push("frame \{index} has an empty canvas")
}
if frame.canvas.width > 65535 || frame.canvas.height > 65535 {
issues.push("frame \{index} exceeds GIF's 16-bit image limit")
}
if frame.delay_cs < 0 || frame.delay_cs > 65535 {
issues.push("frame \{index} delay is outside the GIF range")
}
if frame.left < 0 ||
frame.top < 0 ||
frame.left + frame.canvas.width > self.width ||
frame.top + frame.canvas.height > self.height {
issues.push("frame \{index} lies outside the logical screen")
}
}
issues
}
///|
/// Return the frame occupying a centisecond position, with loop handling.
pub fn Animation::position_at(
self : Animation,
time_cs : Int,
) -> TimelinePosition? {
if self.frames.length() == 0 {
None
} else {
let duration = self.duration_cs()
let safe_time = if time_cs < 0 { 0 } else { time_cs }
let timeline_time = if duration <= 0 { 0 } else { safe_time % duration }
let mut elapsed = 0
let mut result : TimelinePosition? = None
for index, frame in self.frames {
let delay = if frame.delay_cs <= 0 { 1 } else { frame.delay_cs }
if result is None && timeline_time < elapsed + delay {
result = Some(TimelinePosition::{
index,
elapsed_cs: timeline_time - elapsed,
remaining_cs: elapsed + delay - timeline_time,
})
}
elapsed += delay
}
match result {
Some(position) => Some(position)
None =>
Some(TimelinePosition::{
index: self.frames.length() - 1,
elapsed_cs: 0,
remaining_cs: 0,
})
}
}
}
///|
/// Return the frame visible at a centisecond position.
pub fn Animation::frame_at(self : Animation, time_cs : Int) -> Frame? {
match self.position_at(time_cs) {
None => None
Some(position) => Some(self.frames[position.index])
}
}
///|
/// Return the frame indices at every timeline boundary.
pub fn Animation::timeline_indices(self : Animation) -> Array[Int] {
let result : Array[Int] = []
for index, _ in self.frames {
result.push(index)
}
result
}
///|
/// Return cumulative frame start times in centiseconds.
pub fn Animation::timeline_starts(self : Animation) -> Array[Int] {
let result : Array[Int] = []
let mut elapsed = 0
for frame in self.frames {
result.push(elapsed)
elapsed += if frame.delay_cs <= 0 { 1 } else { frame.delay_cs }
}
result
}
///|
/// Replace zero-delay frames with a visible default delay.
pub fn Animation::normalize_delays(
self : Animation,
default_delay_cs? : Int = 1,
) -> Animation {
let safe_delay = if default_delay_cs <= 0 {
1
} else if default_delay_cs > 65535 {
65535
} else {
default_delay_cs
}
let frames : Array[Frame] = []
for frame in self.frames {
frames.push(
if frame.delay_cs <= 0 {
frame.with_delay(delay_cs=safe_delay)
} else {
frame
},
)
}
Animation::{ ..self, frames, }
}
///|
/// Rescale all delays so the total duration approaches `target_cs`.
pub fn Animation::retime(self : Animation, target_cs : Int) -> Animation {
if self.frames.length() == 0 || target_cs <= 0 {
self
} else {
let source = self.duration_cs()
if source <= 0 {
self.normalize_delays(default_delay_cs=target_cs / self.frames.length())
} else {
let frames : Array[Frame] = []
let mut assigned = 0
for index, frame in self.frames {
let delay = if index == self.frames.length() - 1 {
target_cs - assigned
} else {
frame.delay_cs * target_cs / source
}
let safe_delay = if delay < 1 {
1
} else if delay > 65535 {
65535
} else {
delay
}
assigned += safe_delay
frames.push(frame.with_delay(delay_cs=safe_delay))
}
Animation::{ ..self, frames, }
}
}
}
///|
/// Find keyframes by comparing each frame to the previous full canvas.
pub fn Animation::keyframe_indices(
self : Animation,
threshold? : Int = 0,
) -> Array[Int] {
let result : Array[Int] = []
if self.frames.length() == 0 {
result
} else {
let mut previous = self.background_canvas()
for index, frame in self.frames {
match changed_rect(previous, frame.canvas, threshold) {
None => ()
Some(_) => result.push(index)
}
previous = frame.canvas
}
result
}
}
///|
/// Keep only keyframes and preserve each frame's timing.
pub fn Animation::keyframes(
self : Animation,
threshold? : Int = 0,
) -> Animation {
let indices = self.keyframe_indices(threshold~)
let frames : Array[Frame] = []
for index in indices {
frames.push(self.frames[index])
}
Animation::{ ..self, frames, }
}
///|
/// Build a sampled timeline with evenly spaced positions.
pub fn Animation::sample(self : Animation, count : Int) -> Animation {
if count <= 0 || self.frames.length() == 0 {
Animation::{ ..self, frames: [] }
} else {
let frames : Array[Frame] = []
let duration = self.duration_cs()
for index in 0.. ()
Some(frame) =>
frames.push(
frame.with_delay(
delay_cs=if count == 0 { 1 } else { duration / count },
),
)
}
}
Animation::{ ..self, frames, }
}
}
///|
/// Add a reverse pass without duplicating the endpoints.
pub fn Animation::ping_pong(self : Animation) -> Animation {
if self.frames.length() <= 1 {
self
} else {
let frames : Array[Frame] = []
for frame in self.frames {
frames.push(frame)
}
let mut index = self.frames.length() - 2
while index > 0 {
frames.push(self.frames[index])
index -= 1
}
Animation::{ ..self, frames, }
}
}
///|
/// Return one frame's delay, defaulting zero-delay frames to one centisecond.
pub fn Frame::visible_delay_cs(self : Frame) -> Int {
if self.delay_cs <= 0 {
1
} else {
self.delay_cs
}
}
///|
/// Return a frame with its display delay clamped to GIF's 16-bit field.
pub fn Frame::clamp_delay(self : Frame) -> Frame {
let value = if self.delay_cs < 0 {
0
} else if self.delay_cs > 65535 {
65535
} else {
self.delay_cs
}
{ ..self, delay_cs: value }
}
///|
/// Return a copy with frame offsets clipped to the logical screen.
pub fn Animation::clip_offsets(self : Animation) -> Animation {
let frames : Array[Frame] = []
for frame in self.frames {
let left = if frame.left < 0 {
0
} else if frame.left >= self.width {
self.width - 1
} else {
frame.left
}
let top = if frame.top < 0 {
0
} else if frame.top >= self.height {
self.height - 1
} else {
frame.top
}
frames.push({ ..frame, left, top })
}
Animation::{ ..self, frames, }
}