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
OUTPUT
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