Math-driven animation — trig-parameterised hue / swirl / sat / vignette

Renders a full WebP/APNG animation where every frame's hue, swirl, saturation and vignette are functions of t ∈ [0, 1). The trig shapes guarantee smooth motion and a clean loop (value at t=1 equals value at t=0). Tweak each peak slider for distinct moods.

INPUT
INPUT — Math-driven animation — trig-parameterised hue / swirl / sat / vignette
OUTPUT
OUTPUT — Math-driven animation — trig-parameterised hue / swirl / sat / vignette
JavaScript
// Math-driven animation — trig-parameterised hue / swirl / sat / vignette
// demo_animation_frame.js
//!INPUT: INPUT
//!OUTPUT: OUTPUT
//!PARAM: FRAMES:integer=30,min=4,max=240
//!PARAM: DELAY_MS:integer=50,min=10,max=5000
//!PARAM: HUE_CYCLES:number=1,min=0,max=4,step=0.25
//!PARAM: SWIRL_PEAK:number=120,min=0,max=360
//!PARAM: VIG_PEAK:number=0.6,min=0,max=1
//!PARAM: SAT_DEPTH:number=0.35,min=0,max=1
//!PARAM: FORMAT:enum(webp|apng)=webp

// Every parameter is a function of t ∈ [0, 1) so the animation
// loops cleanly: each value at t=1 equals its value at t=0. Tweak
// the peaks to taste; the trig shapes guarantee smooth motion.
//
//   hue        = HUE_CYCLES full rotations across the loop
//   swirl      = sine, ±SWIRL_PEAK degrees (forward → reverse)
//   vignette   = lifted sine, oscillates 0…VIG_PEAK
//   saturation = cosine around 1.0, ±SAT_DEPTH

const src   = Engine.loadImage(INPUT);
const anim  = Engine.animation(src.width, src.height, { loop: 0 });
const cx    = src.width  / 2;
const cy    = src.height / 2;
const swirlRadius = Math.min(src.width, src.height) * 0.4;

const p = Engine.progress("Rendering frames", { total: FRAMES });
for (let i = 0; i < FRAMES; i++) {
    const t = i / FRAMES;                     // [0, 1)
    const hueShift    = t * 360 * HUE_CYCLES;
    const swirlAngle  = Math.sin(t * Math.PI * 2) * SWIRL_PEAK;
    const vigStrength = Math.max(0, Math.sin(t * Math.PI) * VIG_PEAK);
    const satBoost    = 1 + Math.cos(t * Math.PI * 2) * SAT_DEPTH;

    const frame = src.clone()
        .hue(hueShift)
        .swirl(px(cx, cy), swirlRadius, swirlAngle)
        .saturation(satBoost)
        .vignette(vigStrength);

    anim.addFrame(frame, { delay: DELAY_MS });
    frame.free();
    p.step(1, `frame ${i + 1}/${FRAMES} hue=${Math.round(hueShift)}°`);
}
p.done(`✓ ${FRAMES} frames`);

// Final encoder pass — indeterminate spinner since libwebp /
// PNG-anim mux time depends on frame count + resolution.
const enc = Engine.progress(`Encoding ${FORMAT.toUpperCase()}`);
if (FORMAT === "apng") {
    anim.saveAPNG(OUTPUT);
} else {
    // WebP lossy is ~85% smaller than APNG at visually identical quality
    // — better default for a gallery asset.
    anim.saveWebP(OUTPUT, { lossy: true, quality: 85 });
}
enc.done("✓ saved");

anim.free();
src.free();

// © 2026 Michael Lechner · mlc OpticScript · https://mlcgo.eu · Elastic License 2.0