use alpha2_eval::{SampledAnimator, SampledSelector};
use alpha2_model::{RangeShape, RangeUnits, RunState, TextRun, TextUnit};
use alpha2_text::{ClusterState, ColorMix, TextLayout};
use alpha2_time::MediaTime;
#[must_use]
pub fn selection(
selector: &SampledSelector,
layout: &TextLayout,
runs: &[TextRun],
time: MediaTime,
) -> Vec<f32> {
match selector {
SampledSelector::Range {
based_on,
units,
start,
end,
offset,
shape,
amount,
} => {
let (unit_of, count) = units_of(layout, *based_on);
#[allow(clippy::cast_precision_loss)]
let count_f = count as f32;
let (mut s, mut e) = match units {
RangeUnits::Percent => (
(start + offset) / 100.0 * count_f,
(end + offset) / 100.0 * count_f,
),
RangeUnits::Index => (start + offset, end + offset),
};
if s > e {
std::mem::swap(&mut s, &mut e);
}
unit_of
.iter()
.map(|unit| unit.map_or(0.0, |unit| range_value(*shape, unit, s, e) * amount))
.collect()
}
SampledSelector::Timing { state, ramp, sweep } => {
timing(layout, runs, time, *state, *ramp, *sweep)
}
SampledSelector::Class { class } => {
let run_of = run_of_clusters(layout, runs);
run_of
.iter()
.map(|run| {
let selected =
run.is_some_and(|run| runs[run].class.iter().any(|name| name == class));
if selected { 1.0 } else { 0.0 }
})
.collect()
}
}
}
fn units_of(layout: &TextLayout, based_on: TextUnit) -> (Vec<Option<u32>>, u32) {
let clusters = &layout.clusters;
match based_on {
TextUnit::Characters => (
(0..u32::try_from(clusters.len()).unwrap_or(u32::MAX))
.map(Some)
.collect(),
u32::try_from(clusters.len()).unwrap_or(u32::MAX),
),
TextUnit::CharactersExcludingSpaces => {
let mut next = 0;
let units = clusters
.iter()
.map(|cluster| {
(!cluster.is_space).then(|| {
next += 1;
next - 1
})
})
.collect();
(units, next)
}
TextUnit::Words => {
let count = clusters
.iter()
.filter_map(|cluster| cluster.word)
.max()
.map_or(0, |last| last + 1);
(clusters.iter().map(|cluster| cluster.word).collect(), count)
}
TextUnit::Lines => (
clusters.iter().map(|cluster| Some(cluster.line)).collect(),
u32::try_from(layout.lines.len()).unwrap_or(u32::MAX),
),
}
}
#[allow(clippy::cast_precision_loss, clippy::many_single_char_names)]
fn range_value(shape: RangeShape, unit: u32, s: f32, e: f32) -> f32 {
#[allow(clippy::float_cmp)]
if e == s {
return 0.0;
}
let i = unit as f32;
let ramp = || (i + 0.5 - s) / (e - s);
let triangle = |u: f32| {
if (0.0..=1.0).contains(&u) {
1.0 - (2.0 * u - 1.0).abs()
} else {
0.0
}
};
match shape {
RangeShape::Square => (e.min(i + 1.0) - s.max(i)).clamp(0.0, 1.0),
RangeShape::RampUp => ramp().clamp(0.0, 1.0),
RangeShape::RampDown => 1.0 - ramp().clamp(0.0, 1.0),
RangeShape::Triangle => triangle(ramp()),
RangeShape::Round => {
let u = ramp();
if (0.0..=1.0).contains(&u) {
let x = 2.0 * u - 1.0;
(1.0 - x * x).max(0.0).sqrt()
} else {
0.0
}
}
RangeShape::Smooth => {
let t = triangle(ramp());
t * t * (3.0 - 2.0 * t)
}
}
}
fn run_of_clusters(layout: &TextLayout, runs: &[TextRun]) -> Vec<Option<usize>> {
let mut ends = Vec::with_capacity(runs.len());
let mut end = 0;
for run in runs {
end += run.text.len();
ends.push(end);
}
layout
.clusters
.iter()
.map(|cluster| {
let run = ends.partition_point(|&end| end <= cluster.bytes.start);
(run < runs.len()).then_some(run)
})
.collect()
}
#[allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
fn timing(
layout: &TextLayout,
runs: &[TextRun],
time: MediaTime,
state: RunState,
ramp: MediaTime,
sweep: bool,
) -> Vec<f32> {
let run_of = run_of_clusters(layout, runs);
let ramp = ramp.ticks();
let reached = |at: i64| -> f32 {
let past = time.ticks().saturating_sub(at);
if ramp <= 0 {
if past >= 0 { 1.0 } else { 0.0 }
} else {
(past as f64 / ramp as f64).clamp(0.0, 1.0) as f32
}
};
let mut place = vec![(0_usize, 0_usize); run_of.len()];
let mut counts = vec![0_usize; runs.len()];
for (cluster, run) in run_of.iter().enumerate() {
if let Some(run) = run {
place[cluster].0 = counts[*run];
counts[*run] += 1;
}
}
for (cluster, run) in run_of.iter().enumerate() {
if let Some(run) = run {
place[cluster].1 = counts[*run];
}
}
run_of
.iter()
.zip(place)
.map(|(run, (index, count))| {
let Some(span) = run.and_then(|run| runs[run].time) else {
return 0.0;
};
let (start, end) = (span.start.ticks(), span.end.ticks());
let (a, b) = if sweep && count > 0 {
let length = i128::from(end - start);
let share = |at: usize| {
i64::try_from(i128::from(start) + length * at as i128 / count as i128)
.unwrap_or(end)
};
(share(index), share(index + 1))
} else {
(start, end)
};
let (begun, ended) = (reached(a), reached(b));
match state {
RunState::Upcoming => 1.0 - begun,
RunState::Active => begun * (1.0 - ended),
RunState::Spoken => ended,
}
})
.collect()
}
#[must_use]
pub fn cluster_states(
layout: &TextLayout,
runs: &[TextRun],
animators: &[SampledAnimator],
time: MediaTime,
font_size: f32,
) -> (Vec<ClusterState>, u64) {
let mut states = vec![ClusterState::IDENTITY; layout.clusters.len()];
let mut degrees = vec![0.0_f32; states.len()];
for animator in animators {
let weights = selection(&animator.selector, layout, runs, time);
for ((state, degrees), weight) in states.iter_mut().zip(&mut degrees).zip(weights) {
if weight <= 0.0 {
continue;
}
if let Some([x, y]) = animator.offset {
state.offset[0] += weight * x * font_size;
state.offset[1] += weight * y * font_size;
}
if let Some(scale) = animator.scale {
for (axis, target) in state.scale.iter_mut().zip(scale) {
*axis *= 1.0 + weight * (target - 1.0);
}
}
if let Some(rotation) = animator.rotation_degrees {
*degrees += weight * rotation;
}
if let Some(opacity) = animator.opacity {
state.opacity *= 1.0 + weight * (opacity - 1.0);
}
if let Some(target) = animator.fill {
state.fill = Some(mix_toward(state.fill, target, weight));
}
if let Some(target) = animator.stroke {
state.stroke = Some(mix_toward(state.stroke, target, weight));
}
if let Some(tracking) = animator.tracking {
state.tracking += weight * tracking * font_size;
}
}
}
let mut hash = 0xcbf2_9ce4_8422_2325_u64;
let mut feed = |value: f32| {
for byte in value.to_bits().to_le_bytes() {
hash = (hash ^ u64::from(byte)).wrapping_mul(0x0000_0100_0000_01b3);
}
};
let mut identity = true;
for (state, degrees) in states.iter_mut().zip(degrees) {
state.rotation = degrees.to_radians();
state.opacity = state.opacity.clamp(0.0, 1.0);
identity &= state.is_identity();
let mixes = [state.fill, state.stroke].map(|mix| {
mix.map_or([1.0, 0.0, 0.0, 0.0, 0.0], |mix| {
[mix.keep, mix.add[0], mix.add[1], mix.add[2], mix.add[3]]
})
});
for value in state
.offset
.into_iter()
.chain(state.scale)
.chain([state.rotation, state.opacity, state.tracking])
.chain(mixes.into_iter().flatten())
{
feed(value);
}
}
(states, if identity { 0 } else { hash.max(1) })
}
fn mix_toward(mix: Option<ColorMix>, target: [f32; 4], weight: f32) -> ColorMix {
let mix = mix.unwrap_or(ColorMix {
keep: 1.0,
add: [0.0; 4],
});
let target = premultiplied_linear(target);
ColorMix {
keep: mix.keep * (1.0 - weight),
add: std::array::from_fn(|channel| {
mix.add[channel] * (1.0 - weight) + target[channel] * weight
}),
}
}
fn premultiplied_linear(color: [f32; 4]) -> [f32; 4] {
let alpha = color[3].clamp(0.0, 1.0);
let linear = |value: f32| {
let value = value.clamp(0.0, 1.0);
if value >= 1.0 {
1.0
} else if value <= 0.040_45 {
value / 12.92
} else {
libm::powf((value + 0.055) / 1.055, 2.4)
}
};
[
linear(color[0]) * alpha,
linear(color[1]) * alpha,
linear(color[2]) * alpha,
alpha,
]
}
#[cfg(test)]
#[path = "text_animation_tests.rs"]
mod tests;