///|
/// Sampling policies for contact sheets and preview generation.
pub(all) enum AnimationSamplingPolicy {
EveryFrame
Evenly(count~ : Int)
Keyframes(threshold~ : Int)
FirstAndLast
} derive(Eq, Debug)
///|
pub(all) struct ContactSheetOptions {
columns : Int
cell_width : Int
cell_height : Int
gap : Int
background : Rgba
labels : Bool
} derive(Debug)
///|
pub fn ContactSheetOptions::default() -> ContactSheetOptions {
{
columns: 4,
cell_width: 160,
cell_height: 120,
gap: 8,
background: rgb(32, 32, 36),
labels: false,
}
}
///|
pub fn ContactSheetOptions::columns(
self : ContactSheetOptions,
value : Int,
) -> ContactSheetOptions {
{ ..self, columns: if value < 1 { 1 } else { value } }
}
///|
pub fn ContactSheetOptions::cell(
self : ContactSheetOptions,
width : Int,
height : Int,
) -> ContactSheetOptions {
{
..self,
cell_width: if width < 1 {
1
} else {
width
},
cell_height: if height < 1 {
1
} else {
height
},
}
}
///|
pub fn ContactSheetOptions::gap(
self : ContactSheetOptions,
value : Int,
) -> ContactSheetOptions {
{ ..self, gap: if value < 0 { 0 } else { value } }
}
///|
pub fn ContactSheetOptions::background(
self : ContactSheetOptions,
color : Rgba,
) -> ContactSheetOptions {
{ ..self, background: color }
}
///|
pub fn ContactSheetOptions::with_labels(
self : ContactSheetOptions,
enabled : Bool,
) -> ContactSheetOptions {
{ ..self, labels: enabled }
}
///|
fn sampled_indices(
animation : Animation,
policy : AnimationSamplingPolicy,
) -> Array[Int] {
let result : Array[Int] = []
let count = animation.frames.length()
match policy {
EveryFrame =>
for i in 0..
if count > 0 && count > 0 {
let safe_count = if count < 1 { 1 } else { count }
for i in 0.. {
for index in animation.keyframe_indices(threshold~) {
result.push(index)
}
if result.length() == 0 && count > 0 {
result.push(0)
}
}
FirstAndLast => {
if count > 0 {
result.push(0)
}
if count > 1 {
result.push(count - 1)
}
}
}
result
}
///|
/// Extract a deterministic preview animation according to a sampling policy.
pub fn Animation::sample_with_policy(
self : Animation,
policy : AnimationSamplingPolicy,
) -> Animation {
let frames : Array[Frame] = []
for index in sampled_indices(self, policy) {
frames.push(self.frames[index])
}
Animation::{ ..self, frames, }
}
///|
/// Create a tiled contact sheet for review, documentation, and CI artifacts.
pub fn Animation::contact_sheet(
self : Animation,
options? : ContactSheetOptions = ContactSheetOptions::default(),
policy? : AnimationSamplingPolicy = EveryFrame,
) -> Canvas {
let indices = sampled_indices(self, policy)
let columns = if options.columns < 1 { 1 } else { options.columns }
let rows = if indices.length() == 0 {
1
} else {
(indices.length() + columns - 1) / columns
}
let width = columns * options.cell_width + (columns + 1) * options.gap
let height = rows * options.cell_height + (rows + 1) * options.gap
let sheet = Canvas::new(width~, height~, fill=options.background)
for position, index in indices {
let frame = self.frames[index]
let preview = frame
.canvas()
.contain(
width=options.cell_width,
height=options.cell_height,
fill=options.background,
)
let column = position % columns
let row = position / columns
let left = options.gap + column * (options.cell_width + options.gap)
let top = options.gap + row * (options.cell_height + options.gap)
ignore(sheet.blit(preview, destination_x=left, destination_y=top))
if options.labels {
let marker = frame_marker(index, options.cell_width, options.cell_height)
ignore(sheet.blit(marker, destination_x=left, destination_y=top))
}
}
sheet
}
///|
fn frame_marker(index : Int, width : Int, height : Int) -> Canvas {
let marker = Canvas::new(width~, height~, fill=rgba(0, 0, 0, 0))
let shade = (index * 37 % 180 + 50).to_byte()
ignore(
marker.draw_rect(
x=2,
y=2,
width=width - 4,
height=height - 4,
color=rgba(shade, shade, shade, 80),
),
)
marker
}
///|
/// Produce thumbnails in one horizontal strip, preserving frame order.
pub fn Animation::thumbnail_strip(
self : Animation,
max_edge : Int,
gap? : Int = 4,
) -> Canvas {
let safe_gap = if gap < 0 { 0 } else { gap }
let thumbnails : Array[Canvas] = []
let mut width = 0
let mut height = 0
for frame in self.frames {
let thumbnail = frame.canvas().thumbnail(max_edge)
thumbnails.push(thumbnail)
width += thumbnail.width + safe_gap
if thumbnail.height > height {
height = thumbnail.height
}
}
let result = Canvas::new(
width=if width > 0 { width - safe_gap } else { 0 },
height~,
fill=rgba(0, 0, 0, 0),
)
let mut left = 0
for thumbnail in thumbnails {
ignore(result.blit(thumbnail, destination_x=left, destination_y=0))
left += thumbnail.width + safe_gap
}
result
}
///|
/// Build a heatmap of changes between two equally sized canvases.
pub fn difference_heatmap(left : Canvas, right : Canvas) -> Canvas {
let width = if left.width < right.width { left.width } else { right.width }
let height = if left.height < right.height {
left.height
} else {
right.height
}
let result = Canvas::new(width~, height~, fill=rgba(0, 0, 0, 0))
for y in 0.. 255 { 255 } else { difference }
let blue = if difference > 255 { 255 } else { 255 - difference }
result.pixels[x + y * width] = rgba(red.to_byte(), 0, blue.to_byte(), 255)
}
}
result
}
///|
/// Generate one difference heatmap per adjacent frame pair.
pub fn Animation::difference_frames(self : Animation) -> Array[Canvas] {
let result : Array[Canvas] = []
if self.frames.length() > 1 {
for i in 0..<(self.frames.length() - 1) {
result.push(
difference_heatmap(self.frames[i].canvas(), self.frames[i + 1].canvas()),
)
}
}
result
}
///|
/// Return cumulative timeline positions for storyboard labels and scrubbing.
pub fn Animation::timecodes(self : Animation) -> Array[Int] {
let result : Array[Int] = []
let mut elapsed = 0
for frame in self.frames {
result.push(elapsed)
elapsed += frame.delay_cs()
}
result
}
///|
/// Return frames whose timeline position is closest to each requested time.
pub fn Animation::frames_at_times(
self : Animation,
times : ArrayView[Int],
) -> Array[Frame] {
let result : Array[Frame] = []
for time in times {
match self.frame_at(time) {
None => ()
Some(frame) => result.push(frame)
}
}
result
}
///|
/// Create a poster canvas by placing sampled thumbnails on a checkerboard.
pub fn Animation::poster(
self : Animation,
max_edge : Int,
columns : Int,
) -> Canvas {
let options = ContactSheetOptions::default()
.cell(max_edge, max_edge)
.columns(columns)
self.contact_sheet(
options~,
policy=Evenly(
count=if self.frames.length() < 12 { self.frames.length() } else { 12 },
),
)
}
///|
/// Return the average frame color sequence for lightweight visual analytics.
pub fn Animation::average_colors(self : Animation) -> Array[Rgba] {
let result : Array[Rgba] = []
for frame in self.frames {
result.push(frame.canvas().average_color())
}
result
}
///|
/// Compute a compact temporal activity score from adjacent frame differences.
pub fn Animation::activity_scores(self : Animation) -> Array[Int] {
let result : Array[Int] = []
if self.frames.length() == 0 {
return result
}
result.push(0)
for i in 1..