///|
/// A declarative export workflow for applications that need a stable GIF
/// pipeline instead of assembling encoder flags at each call site.
///
/// The workflow keeps visual stages, timing edits, safety checks, and the
/// export profile in one value. It is intentionally independent of files or
/// operating-system APIs, so the same plan can run in a CLI, a browser host,
/// or an embedded MoonBit service.
pub(all) enum GifWorkflowStep {
Visual(stage~ : PipelineStage)
Resize(width~ : Int, height~ : Int)
SetDelay(delay_cs~ : Int)
SetLoop(loop_count~ : Int?)
SetBackground(color~ : Rgba)
SetProfile(profile~ : ExportProfile)
ClampLimits
NormalizeTimeline
} derive(Debug)
///|
/// A reusable, inspectable GIF export plan.
pub(all) struct GifWorkflow {
steps : Array[GifWorkflowStep]
profile : ExportProfile
limits : GifLimits
comment : String?
reject_empty : Bool
} derive(Debug)
///|
/// Metrics returned by a workflow before or after encoding.
pub(all) struct GifWorkflowReport {
valid : Bool
issues : Array[String]
input_width : Int
input_height : Int
output_width : Int
output_height : Int
input_frames : Int
output_frames : Int
input_pixels : Int
output_pixels : Int
encoded_bytes : Int
estimated_bytes : Int
comment_bytes : Int
stage_count : Int
profile : ExportProfile
} derive(Debug)
///|
pub fn GifWorkflow::new() -> GifWorkflow {
{
steps: [],
profile: Balanced,
limits: GifLimits::default(),
comment: None,
reject_empty: true,
}
}
///|
pub fn GifWorkflow::default() -> GifWorkflow {
GifWorkflow::new()
}
///|
/// Append a low-level visual pipeline stage.
pub fn GifWorkflow::visual(
self : GifWorkflow,
stage : PipelineStage,
) -> GifWorkflow {
self.steps.push(Visual(stage~))
self
}
///|
/// Append an explicit nearest-neighbour resize stage.
pub fn GifWorkflow::resize(
self : GifWorkflow,
width : Int,
height : Int,
) -> GifWorkflow {
self.steps.push(Resize(width~, height~))
self
}
///|
pub fn GifWorkflow::grayscale(self : GifWorkflow) -> GifWorkflow {
self.visual(Gray)
}
///|
pub fn GifWorkflow::invert(self : GifWorkflow) -> GifWorkflow {
self.visual(InvertColors)
}
///|
pub fn GifWorkflow::blur(self : GifWorkflow, radius : Int) -> GifWorkflow {
self.visual(Blur(radius~))
}
///|
pub fn GifWorkflow::gaussian_blur(self : GifWorkflow) -> GifWorkflow {
self.visual(GaussianBlur)
}
///|
pub fn GifWorkflow::sharpen(self : GifWorkflow) -> GifWorkflow {
self.visual(Sharpen)
}
///|
pub fn GifWorkflow::edge_map(self : GifWorkflow) -> GifWorkflow {
self.visual(Edge)
}
///|
pub fn GifWorkflow::posterize(self : GifWorkflow, levels : Int) -> GifWorkflow {
self.visual(Posterize(levels~))
}
///|
pub fn GifWorkflow::threshold(self : GifWorkflow, level : Byte) -> GifWorkflow {
self.visual(Threshold(level~))
}
///|
pub fn GifWorkflow::normalize_luma(self : GifWorkflow) -> GifWorkflow {
self.visual(Normalize)
}
///|
pub fn GifWorkflow::ordered_dither(
self : GifWorkflow,
levels : Int,
) -> GifWorkflow {
self.visual(Dither(levels~))
}
///|
pub fn GifWorkflow::pixelate(self : GifWorkflow, block : Int) -> GifWorkflow {
self.visual(Pixelate(block~))
}
///|
pub fn GifWorkflow::grain(
self : GifWorkflow,
amount : Int,
seed : Int,
) -> GifWorkflow {
self.visual(Grain(amount~, seed~))
}
///|
pub fn GifWorkflow::tint(
self : GifWorkflow,
color : Rgba,
strength : Int,
) -> GifWorkflow {
self.visual(Tint(color~, strength~))
}
///|
pub fn GifWorkflow::adjust(
self : GifWorkflow,
adjustment : ColorAdjustment,
) -> GifWorkflow {
self.visual(Adjust(adjustment~))
}
///|
pub fn GifWorkflow::convolve(
self : GifWorkflow,
kernel : ConvolutionKernel,
) -> GifWorkflow {
self.visual(Convolve(kernel~))
}
///|
pub fn GifWorkflow::dilate(
self : GifWorkflow,
radius : Int,
mode : MorphologyMode,
) -> GifWorkflow {
self.visual(Dilate(radius~, mode~))
}
///|
pub fn GifWorkflow::erode(
self : GifWorkflow,
radius : Int,
mode : MorphologyMode,
) -> GifWorkflow {
self.visual(Erode(radius~, mode~))
}
///|
pub fn GifWorkflow::chroma_key(
self : GifWorkflow,
key : Rgba,
tolerance : Int,
) -> GifWorkflow {
self.visual(ChromaKey(key~, tolerance~))
}
///|
pub fn GifWorkflow::crop(
self : GifWorkflow,
x : Int,
y : Int,
width : Int,
height : Int,
) -> GifWorkflow {
self.visual(Crop(x~, y~, width~, height~))
}
///|
pub fn GifWorkflow::flip_horizontal(self : GifWorkflow) -> GifWorkflow {
self.visual(FlipHorizontal)
}
///|
pub fn GifWorkflow::flip_vertical(self : GifWorkflow) -> GifWorkflow {
self.visual(FlipVertical)
}
///|
/// Set a constant frame delay in centiseconds.
pub fn GifWorkflow::delay(self : GifWorkflow, delay_cs : Int) -> GifWorkflow {
self.steps.push(SetDelay(delay_cs~))
self
}
///|
pub fn GifWorkflow::loop_count(self : GifWorkflow, count : Int?) -> GifWorkflow {
self.steps.push(SetLoop(loop_count=count))
self
}
///|
pub fn GifWorkflow::background(self : GifWorkflow, color : Rgba) -> GifWorkflow {
self.steps.push(SetBackground(color~))
self
}
///|
pub fn GifWorkflow::profile(
self : GifWorkflow,
profile : ExportProfile,
) -> GifWorkflow {
self.steps.push(SetProfile(profile~))
{ ..self, profile, }
}
///|
pub fn GifWorkflow::clamp_limits(self : GifWorkflow) -> GifWorkflow {
self.steps.push(ClampLimits)
self
}
///|
pub fn GifWorkflow::normalize_timeline(self : GifWorkflow) -> GifWorkflow {
self.steps.push(NormalizeTimeline)
self
}
///|
/// Add a UTF-8 comment extension to the final GIF.
pub fn GifWorkflow::comment(self : GifWorkflow, text : String?) -> GifWorkflow {
{ ..self, comment: text }
}
///|
pub fn GifWorkflow::with_limits(
self : GifWorkflow,
limits : GifLimits,
) -> GifWorkflow {
{ ..self, limits, }
}
///|
/// Empty animations can be useful as intermediate values, but export jobs
/// normally reject them so a missing input cannot silently create a trailer.
pub fn GifWorkflow::allow_empty(self : GifWorkflow) -> GifWorkflow {
{ ..self, reject_empty: false }
}
///|
pub fn GifWorkflow::step_count(self : GifWorkflow) -> Int {
self.steps.length()
}
///|
pub fn GifWorkflow::steps(self : GifWorkflow) -> ArrayView[GifWorkflowStep] {
self.steps[:]
}
///|
pub fn GifWorkflow::selected_profile(self : GifWorkflow) -> ExportProfile {
self.profile
}
///|
pub fn GifWorkflow::limits(self : GifWorkflow) -> GifLimits {
self.limits
}
///|
pub fn GifWorkflow::comment_text(self : GifWorkflow) -> String? {
self.comment
}
///|
pub fn GifWorkflow::has_comment(self : GifWorkflow) -> Bool {
self.comment is Some(_)
}
///|
fn workflow_step_label(step : GifWorkflowStep) -> String {
match step {
Visual(stage~) =>
match stage {
Gray => "grayscale"
InvertColors => "invert"
Blur(_) => "blur"
GaussianBlur => "gaussian-blur"
Sharpen => "sharpen"
Edge => "edge"
Posterize(_) => "posterize"
Threshold(_) => "threshold"
Normalize => "normalize"
Dither(_) => "dither"
Pixelate(_) => "pixelate"
Grain(..) => "grain"
Tint(..) => "tint"
Adjust(..) => "adjust"
Convolve(..) => "convolve"
Dilate(..) => "dilate"
Erode(..) => "erode"
ChromaKey(..) => "chroma-key"
Crop(..) => "crop"
Resize(..) => "resize"
FlipHorizontal => "flip-horizontal"
FlipVertical => "flip-vertical"
}
Resize(..) => "resize"
SetDelay(..) => "delay"
SetLoop(..) => "loop"
SetBackground(..) => "background"
SetProfile(..) => "profile"
ClampLimits => "clamp-limits"
NormalizeTimeline => "normalize-timeline"
}
}
///|
fn workflow_profile_label(profile : ExportProfile) -> String {
match profile {
Preview => "preview"
Balanced => "balanced"
SmallFile => "small-file"
Archive => "archive"
Sticker => "sticker"
}
}
///|
/// Return stable labels suitable for logs, telemetry, and a CLI dry run.
pub fn GifWorkflow::labels(self : GifWorkflow) -> Array[String] {
let result : Array[String] = []
for step in self.steps {
result.push(workflow_step_label(step))
}
result
}
///|
fn workflow_visual_pipeline(workflow : GifWorkflow) -> CanvasPipeline {
let pipeline = CanvasPipeline::new()
for step in workflow.steps {
match step {
Visual(stage~) => ignore(pipeline.add(stage))
Resize(width~, height~) => ignore(pipeline.resize(width, height))
_ => ()
}
}
pipeline
}
///|
/// Apply only visual stages to one canvas. Timing and metadata steps are
/// intentionally ignored here so callers can preview an image independently.
pub fn GifWorkflow::apply_canvas(self : GifWorkflow, canvas : Canvas) -> Canvas {
workflow_visual_pipeline(self).apply(canvas)
}
///|
fn workflow_apply_metadata(
workflow : GifWorkflow,
animation : Animation,
) -> Animation {
let mut result = animation
for step in workflow.steps {
match step {
SetDelay(delay_cs~) => result = result.with_delay(delay_cs)
SetLoop(loop_count~) => result = result.with_loop_count(loop_count~)
SetBackground(color~) => result = result.with_background(background=color)
ClampLimits => result = result.clamp_limits(workflow.limits)
NormalizeTimeline => result = result.normalize_delays()
SetProfile(_) | Visual(_) | Resize(_) => ()
}
}
result
}
///|
/// Apply visual stages while keeping timing, disposal, offsets, and loop data.
pub fn GifWorkflow::apply_animation(
self : GifWorkflow,
animation : Animation,
) -> Animation {
let prepared = workflow_apply_metadata(self, animation)
let pipeline = workflow_visual_pipeline(self)
let frames : Array[Frame] = []
let mut output_width = prepared.width
let mut output_height = prepared.height
for frame in prepared.frames {
let transformed = pipeline.apply(frame.canvas())
if frames.length() == 0 {
output_width = transformed.width
output_height = transformed.height
}
frames.push(frame.with_canvas(transformed))
}
let result = Animation::new(width=output_width, height=output_height)
.with_loop_count(loop_count=prepared.loop_count)
.with_background(background=prepared.background)
for frame in frames {
ignore(result.add_frame(frame))
}
result
}
///|
fn workflow_comment_bytes(workflow : GifWorkflow) -> Int {
match workflow.comment {
None => 0
Some(text) => @utf8.encode(text).length()
}
}
///|
fn workflow_check(
workflow : GifWorkflow,
animation : Animation,
) -> Array[String] {
let issues : Array[String] = []
if workflow.reject_empty && animation.frame_count() == 0 {
issues.push("animation has no frames")
}
if !workflow.limits.valid_dimensions(animation.width, animation.height) {
issues.push("logical screen dimensions are outside configured limits")
}
if animation.frame_count() > workflow.limits.frames() {
issues.push("frame count exceeds configured limits")
}
if workflow_comment_bytes(workflow) > workflow.limits.comment_bytes() {
issues.push("comment exceeds configured byte limit")
}
for frame in animation.frames {
let canvas = frame.canvas()
if !workflow.limits.valid_dimensions(canvas.width, canvas.height) {
issues.push("frame dimensions are outside configured limits")
}
if frame.delay_cs() > workflow.limits.delay() {
issues.push("frame delay exceeds configured limits")
}
}
issues
}
///|
/// Validate a source animation without changing it or touching the encoder.
pub fn GifWorkflow::validate(
self : GifWorkflow,
animation : Animation,
) -> Array[String] {
workflow_check(self, animation)
}
///|
pub fn GifWorkflowReport::valid(self : GifWorkflowReport) -> Bool {
self.valid
}
///|
pub fn GifWorkflowReport::issues(self : GifWorkflowReport) -> ArrayView[String] {
self.issues[:]
}
///|
pub fn GifWorkflowReport::input_dimensions(
self : GifWorkflowReport,
) -> (Int, Int) {
(self.input_width, self.input_height)
}
///|
pub fn GifWorkflowReport::output_dimensions(
self : GifWorkflowReport,
) -> (Int, Int) {
(self.output_width, self.output_height)
}
///|
pub fn GifWorkflowReport::input_frames(self : GifWorkflowReport) -> Int {
self.input_frames
}
///|
pub fn GifWorkflowReport::output_frames(self : GifWorkflowReport) -> Int {
self.output_frames
}
///|
pub fn GifWorkflowReport::input_pixels(self : GifWorkflowReport) -> Int {
self.input_pixels
}
///|
pub fn GifWorkflowReport::output_pixels(self : GifWorkflowReport) -> Int {
self.output_pixels
}
///|
pub fn GifWorkflowReport::encoded_bytes(self : GifWorkflowReport) -> Int {
self.encoded_bytes
}
///|
pub fn GifWorkflowReport::estimated_bytes(self : GifWorkflowReport) -> Int {
self.estimated_bytes
}
///|
pub fn GifWorkflowReport::comment_bytes(self : GifWorkflowReport) -> Int {
self.comment_bytes
}
///|
pub fn GifWorkflowReport::stage_count(self : GifWorkflowReport) -> Int {
self.stage_count
}
///|
pub fn GifWorkflowReport::profile(self : GifWorkflowReport) -> ExportProfile {
self.profile
}
///|
/// Build a report from transformed frames and the exact optimizer estimate.
pub fn GifWorkflow::report(
self : GifWorkflow,
animation : Animation,
) -> GifWorkflowReport raise GifError {
let issues = self.validate(animation)
let transformed = self.apply_animation(animation)
let plan = transformed.plan_export(self.profile)
let optimized = transformed.optimize_for_size(plan.optimization())
let estimated = optimized
.optimization_report(plan.optimization())
.estimated_bytes()
let mut output_pixels = 0
for frame in optimized.frames {
output_pixels += frame.canvas().len()
}
GifWorkflowReport::{
valid: issues.length() == 0,
issues,
input_width: animation.width,
input_height: animation.height,
output_width: transformed.width,
output_height: transformed.height,
input_frames: animation.frame_count(),
output_frames: optimized.frame_count(),
input_pixels: animation.width * animation.height * animation.frame_count(),
output_pixels,
encoded_bytes: 0,
estimated_bytes: estimated + workflow_comment_bytes(self),
comment_bytes: workflow_comment_bytes(self),
stage_count: self.step_count(),
profile: self.profile,
}
}
///|
/// Encode the workflow and return its exact output. Validation is reported by
/// the normal GIF error channel only when the source is structurally invalid;
/// callers can inspect `validate` first when they need all diagnostics.
pub fn GifWorkflow::encode(
self : GifWorkflow,
animation : Animation,
) -> Bytes raise GifError {
let transformed = self.apply_animation(animation)
let plan = transformed.plan_export(self.profile)
let optimized = transformed.optimize_for_size(plan.optimization())
match self.comment {
None => encode_with_options(optimized, plan.encode())
Some(text) => encode_with_comment(optimized, text, options=plan.encode())
}
}
///|
/// Encode and fill the report's measured byte field after the exact export.
pub fn GifWorkflow::encode_report(
self : GifWorkflow,
animation : Animation,
) -> GifWorkflowReport raise GifError {
let report = self.report(animation)
let encoded = self.encode(animation)
{ ..report, encoded_bytes: encoded.length() }
}
///|
/// Produce a compact deterministic dry-run line for a command-line preview.
pub fn GifWorkflow::dry_run(
self : GifWorkflow,
animation : Animation,
) -> String {
let transformed = self.apply_animation(animation)
let labels = self.labels()
let label_text = if labels.length() == 0 {
"none"
} else {
let mut result = labels[0]
for i in 1..[^<>]+?)"\>(?[^\/<>]+?)\<\/a\>/g
},
hljs.COMMENT(
'//', // begin
'\n', // end
)
]
}
}
hljs.registerLanguage('moonbit', moonbitLanguageFn);
hljs.highlightAll();
hljs.initLineNumbersOnLoad();
const number = window.location.href.split('#')[1];
function waitForLineNumbers() {
setTimeout(function () {
const target = document.querySelector(`.hljs-ln-line[data-line-number="${number}"]`);
if (target == null) waitForLineNumbers();
else target.scrollIntoView();
}, 50);
}
waitForLineNumbers()