///|
/// A connection token follows its final path in the element's own coordinates.
/// A visit identifies a real bound node rather than inventing a connector.
pub(all) enum GifTarget {
Connection(
id~ : String,
path~ : String,
mask~ : String?,
point~ : @svg.ConnectorPoint
)
Visit(@svg.NodeBindingKind)
}
///|
pub struct GifOccurrence {
occurrence : @animation.Active
target : GifTarget
}
///|
/// The pure plan sample is retained alongside the measured drawing targets.
pub struct GifFrameSample {
time_ms : Int64
delay_centiseconds : Int32
sample : @animation.Sample
occurrences : ReadOnlyArray[GifOccurrence]
}
///|
pub struct GifFrameResult {
frame : GifFrameSample?
diagnostics : ReadOnlyArray[RasterDiagnostic]
}
///|
/// Validated, immutable sampling state. Frame queries do not mutate a scene,
/// a plan or another frame, and do not read a wall clock.
pub struct GifSampler {
frame_count : Int32
width : Int32
height : Int32
options : GifOptions
priv scene : @svg.Scene
priv plan : @animation.PlaybackPlan
priv times : ReadOnlyArray[(Int64, Int32)]
priv paths : Map[String, (@svg.PathBinding, GifPath)]
priv nodes : Map[String, @svg.NodeBindingKind]
}
///|
pub struct GifSamplerResult {
sampler : GifSampler?
diagnostics : ReadOnlyArray[RasterDiagnostic]
}
///|
fn gif_diagnostic(
code : String,
message : String,
source_id : String,
) -> ReadOnlyArray[RasterDiagnostic] {
[{ code, message, source_id: Some(source_id), }]
}
///|
/// Validate source identity, the logical interval and bounded path work.
pub fn GifSampler::new(
scene : @svg.Scene,
plan : @animation.PlaybackPlan,
options~ : GifOptions = GifOptions::new(),
limits~ : RasterLimits = RasterLimits::default(),
) -> GifSamplerResult {
if !plan.matches(scene.catalog) {
return {
sampler: None,
diagnostics: gif_diagnostic(
"raster/plan-mismatch",
"GIF plan and scene name different source diagrams",
scene.catalog.view_id,
),
}
}
try {
validate_raster_limits(limits)
guard plan.duration_ms > 0L && !plan.steps.is_empty() else {
raise GifInvalid(
"GIF playback needs a positive duration and at least one occurrence",
)
}
guard scene.display_svg.length() <= limits.max_source_units else {
raise GifInvalid("GIF scene exceeds the source budget")
}
if plan.mode == Finite &&
options.interval is Interval(end_ms~, ..) &&
end_ms > plan.duration_ms {
raise GifInvalid("GIF interval exceeds the finite playback plan")
}
let (width, height) = still_dimensions(
scene.canonical_svg,
options.size,
limits,
)
let pixels = width.to_int64() * height.to_int64()
let times = gif_times(plan.duration_ms, options, limits, pixels~)
guard limits.max_svg_nodes > 0 &&
scene.paths.length() <= limits.max_svg_nodes &&
scene.nodes.length() <= limits.max_svg_nodes else {
raise GifInvalid("GIF scene binding budget exhausted")
}
let paths : Map[String, (@svg.PathBinding, GifPath)] = Map([])
let mut remaining = limits.max_svg_nodes
for binding in scene.paths {
guard @svg.ConnectorPath::parse(binding.path) is Some(path) else {
raise GifInvalid("Malformed bound connector path: " + binding.id)
}
let geometry = gif_path(path, remaining.min(8192))
remaining -= geometry.segments.length()
paths[binding.occurrence.key] = (binding, geometry)
}
let nodes : Map[String, @svg.NodeBindingKind] = Map([])
for binding in scene.nodes {
nodes[binding.endpoint] = binding.kind
}
let sampler = GifSampler::{
frame_count: times.length(),
width,
height,
options,
scene,
plan,
times: ReadOnlyArray::from_array(times),
paths,
nodes,
}
{ sampler: Some(sampler), diagnostics: [], }
} catch {
GifInvalid(message) =>
{
sampler: None,
diagnostics: gif_diagnostic(
"raster/gif-options",
message,
scene.catalog.view_id,
),
}
RasterInvalid(code, message, source_id) =>
{
sampler: None,
diagnostics: [
{
code,
message,
source_id: Some(source_id.unwrap_or(scene.catalog.view_id)),
},
],
}
error =>
{
sampler: None,
diagnostics: gif_diagnostic(
"raster/gif-preparation",
error.to_string(),
scene.catalog.view_id,
),
}
}
}
///|
/// Sample one logical frame, including repeated and concurrent occurrences.
pub fn GifSampler::frame(self : GifSampler, index : Int32) -> GifFrameResult {
if index < 0 || index >= self.frame_count {
return {
frame: None,
diagnostics: gif_diagnostic(
"raster/frame-index",
"GIF frame index is outside the prepared interval",
self.scene.catalog.view_id,
),
}
}
let (time_ms, delay_centiseconds) = self.times[index]
let sample = self.plan.sample_at(time_ms)
let occurrences = []
for occurrence in sample.active {
let target = if occurrence.visit {
guard self.nodes.get(occurrence.key) is Some(kind) else {
return {
frame: None,
diagnostics: gif_diagnostic(
"raster/missing-binding",
"GIF visit has no visible node: " + occurrence.key,
self.scene.catalog.view_id,
),
}
}
GifTarget::Visit(kind)
} else {
guard self.paths.get(occurrence.key) is Some((binding, geometry)) else {
return {
frame: None,
diagnostics: gif_diagnostic(
"raster/missing-binding",
"GIF occurrence has no visible path: " + occurrence.key,
self.scene.catalog.view_id,
),
}
}
let progress = occurrence.numerator.to_double() /
occurrence.denominator.to_double()
let point = geometry.point(
if occurrence.reversed {
1.0 - progress
} else {
progress
},
)
GifTarget::Connection(
id=binding.id,
path=binding.path,
mask=binding.mask,
point~,
)
}
occurrences.push(GifOccurrence::{ occurrence, target, })
}
{
frame: Some({
time_ms,
delay_centiseconds,
sample,
occurrences: ReadOnlyArray::from_array(occurrences),
}),
diagnostics: [],
}
}