///|
/// A validation level suitable for command-line diagnostics and CI reports.
pub(all) enum DiagnosticLevel {
Info
Warning
Error
} derive(Eq, Debug)
///|
pub(all) struct GifDiagnostic {
level : DiagnosticLevel
code : String
message : String
frame_index : Int?
} derive(Debug)
///|
pub fn GifDiagnostic::level(self : GifDiagnostic) -> DiagnosticLevel {
self.level
}
///|
pub fn GifDiagnostic::code(self : GifDiagnostic) -> String {
self.code
}
///|
pub fn GifDiagnostic::message(self : GifDiagnostic) -> String {
self.message
}
///|
pub fn GifDiagnostic::frame(self : GifDiagnostic) -> Int? {
self.frame_index
}
///|
pub(all) struct GifValidationReport {
diagnostics : Array[GifDiagnostic]
frame_count : Int
duration_cs : Int
pixel_count : Int
} derive(Debug)
///|
pub fn GifValidationReport::diagnostics(
self : GifValidationReport,
) -> ArrayView[GifDiagnostic] {
self.diagnostics[:]
}
///|
pub fn GifValidationReport::frame_count(self : GifValidationReport) -> Int {
self.frame_count
}
///|
pub fn GifValidationReport::duration_cs(self : GifValidationReport) -> Int {
self.duration_cs
}
///|
pub fn GifValidationReport::pixel_count(self : GifValidationReport) -> Int {
self.pixel_count
}
///|
pub fn GifValidationReport::errors(self : GifValidationReport) -> Int {
let mut count = 0
for diagnostic in self.diagnostics {
if diagnostic.level == Error {
count += 1
}
}
count
}
///|
pub fn GifValidationReport::warnings(self : GifValidationReport) -> Int {
let mut count = 0
for diagnostic in self.diagnostics {
if diagnostic.level == Warning {
count += 1
}
}
count
}
///|
pub fn GifValidationReport::is_valid(self : GifValidationReport) -> Bool {
self.errors() == 0
}
///|
fn add_diagnostic(
list : Array[GifDiagnostic],
level : DiagnosticLevel,
code : String,
message : String,
frame_index : Int?,
) -> Unit {
list.push(GifDiagnostic::{ level, code, message, frame_index })
}
///|
fn check_frame_geometry(
animation : Animation,
frame : Frame,
index : Int,
diagnostics : Array[GifDiagnostic],
) -> Unit {
let canvas = frame.canvas()
let (left, top) = frame.offset()
if canvas.width <= 0 || canvas.height <= 0 {
add_diagnostic(
diagnostics,
Error,
"FRAME_EMPTY",
"frame dimensions must be positive",
Some(index),
)
}
if left < 0 ||
top < 0 ||
left + canvas.width > animation.width ||
top + canvas.height > animation.height {
add_diagnostic(
diagnostics,
Error,
"FRAME_BOUNDS",
"frame rectangle exceeds the logical screen",
Some(index),
)
}
if frame.delay_cs() < 0 {
add_diagnostic(
diagnostics,
Error,
"FRAME_DELAY",
"frame delay cannot be negative",
Some(index),
)
} else if frame.delay_cs() == 0 {
add_diagnostic(
diagnostics,
Warning,
"FRAME_ZERO_DELAY",
"zero delay is legal but may be clamped by viewers",
Some(index),
)
}
if frame.transparent is Some(color) && color.alpha() != 0 {
add_diagnostic(
diagnostics,
Warning,
"TRANSPARENT_ALPHA",
"transparent key colors are normally encoded with alpha zero",
Some(index),
)
}
}
///|
/// Validate an in-memory animation before it reaches the encoder.
pub fn validate_animation(animation : Animation) -> GifValidationReport {
let diagnostics : Array[GifDiagnostic] = []
if animation.width <= 0 || animation.height <= 0 {
add_diagnostic(
diagnostics,
Error,
"SCREEN_DIMENSIONS",
"logical screen dimensions must be positive",
None,
)
}
if animation.width > 65535 || animation.height > 65535 {
add_diagnostic(
diagnostics,
Error,
"SCREEN_LIMIT",
"GIF logical screen dimensions exceed 16-bit limits",
None,
)
}
if animation.frames.length() == 0 {
add_diagnostic(
diagnostics,
Error,
"NO_FRAMES",
"animation must contain at least one frame",
None,
)
}
match animation.loop_count {
None => ()
Some(loop_count) if loop_count < 0 =>
add_diagnostic(
diagnostics,
Error,
"LOOP_COUNT",
"loop count cannot be negative",
None,
)
Some(_) => ()
}
let mut duration = 0
let mut pixels = 0
for index, frame in animation.frames {
check_frame_geometry(animation, frame, index, diagnostics)
duration += frame.delay_cs()
pixels += frame.canvas().len()
}
if duration > 0 && duration < 2 {
add_diagnostic(
diagnostics,
Info,
"SHORT_ANIMATION",
"total duration is shorter than two centiseconds",
None,
)
}
GifValidationReport::{
diagnostics,
frame_count: animation.frames.length(),
duration_cs: duration,
pixel_count: pixels,
}
}
///|
/// Validate encoded bytes and preserve a concise diagnostic report on failure.
pub fn validate_gif_bytes(data : Bytes) -> GifValidationReport {
let diagnostics : Array[GifDiagnostic] = []
try {
let info = inspect(data)
GifValidationReport::{
diagnostics: [],
frame_count: info.frame_count(),
duration_cs: 0,
pixel_count: info.width() * info.height() * info.frame_count(),
}
} catch {
error => {
let (code, message) = match error {
InvalidSignature => ("SIGNATURE", "GIF signature is missing or invalid")
_ => ("MALFORMED", "GIF contains a truncated or invalid block")
}
add_diagnostic(diagnostics, Error, code, message, None)
GifValidationReport::{
diagnostics,
frame_count: 0,
duration_cs: 0,
pixel_count: 0,
}
}
} noraise {
_ =>
GifValidationReport::{
diagnostics,
frame_count: 0,
duration_cs: 0,
pixel_count: 0,
}
}
}
///|
/// Produce a normalized animation suitable for a best-effort export.
pub fn repair_animation(animation : Animation) -> Animation {
let width = if animation.width < 1 {
1
} else if animation.width > 65535 {
65535
} else {
animation.width
}
let height = if animation.height < 1 {
1
} else if animation.height > 65535 {
65535
} else {
animation.height
}
let result = Animation::new(width~, height~)
let loop_count = match animation.loop_count {
None => None
Some(value) =>
Some(if value < 0 { 0 } else if value > 65535 { 65535 } else { value })
}
let repaired = result
.with_loop_count(loop_count~)
.with_background(background=animation.background)
for frame in animation.frames {
let source = frame.canvas()
let left = if frame.left < 0 { 0 } else { frame.left }
let top = if frame.top < 0 { 0 } else { frame.top }
let available_width = width - left
let available_height = height - top
if available_width > 0 &&
available_height > 0 &&
source.width > 0 &&
source.height > 0 {
let crop_width = if source.width > available_width {
available_width
} else {
source.width
}
let crop_height = if source.height > available_height {
available_height
} else {
source.height
}
let cropped = source.crop(x=0, y=0, width=crop_width, height=crop_height)
let safe_delay = if frame.delay_cs < 0 {
0
} else if frame.delay_cs > 65535 {
65535
} else {
frame.delay_cs
}
ignore(
repaired.add_frame(
frame
.with_canvas(cropped)
.with_offset(left~, top~)
.with_delay(delay_cs=safe_delay),
),
)
}
}
if repaired.frames.length() == 0 {
ignore(
repaired.add_frame(
Frame::new(
canvas=Canvas::new(width~, height~, fill=animation.background),
delay_cs=0,
),
),
)
}
repaired
}
///|
/// Return the first error code, if any, for simple command-line integrations.
pub fn GifValidationReport::first_error(self : GifValidationReport) -> String? {
for diagnostic in self.diagnostics {
if diagnostic.level == Error {
return Some(diagnostic.code())
}
}
None
}
///|
/// Count diagnostics for one frame, useful for editor overlays.
pub fn GifValidationReport::frame_diagnostics(
self : GifValidationReport,
frame_index : Int,
) -> Int {
let mut count = 0
for diagnostic in self.diagnostics {
if diagnostic.frame() == Some(frame_index) {
count += 1
}
}
count
}
///|
/// Validate, repair, and return both versions for an import pipeline.
pub fn validate_and_repair(
animation : Animation,
) -> (GifValidationReport, Animation, GifValidationReport) {
let before = validate_animation(animation)
let repaired = repair_animation(animation)
let after = validate_animation(repaired)
(before, repaired, after)
}