Animation demo — 1-second fade-in of an image

Demonstrates how to create a simple fade-in animation by iteratively adjusting the brightness of a source image and adding frames to an animation container.

INPUT
INPUT — Animation demo — 1-second fade-in of an image
OUTPUT
OUTPUT — Animation demo — 1-second fade-in of an image
JavaScript
// Animation demo — 1-second fade-in of an image
// anim_fade_in.js
//!INPUT: INPUT
//!OUTPUT: OUTPUT
//!PARAM: FRAMES:integer=20,min=1,max=200
//!PARAM: DELAY_MS:integer=50,min=10,max=5000
//!PARAM: QUALITY:integer=90,min=50,max=100

// Why QUALITY=90 instead of the usual lossy WebP default (85): a
// fade-in spends a lot of its time in the near-black range where
// every frame differs from its neighbour by ~1/(FRAMES-1) brightness
// step. At q=85 the encoder doesn't have enough bits per macroblock
// to represent those tiny deltas cleanly and visible block artifacts
// appear in the dark frames. Crank to 90 and the headroom is back
// for the cost of ~10–15 % larger file. Drop to 80 to see what the
// artifacts look like; bump to 100 for fully lossless.

// Engine.animation(w, h, { loop }) creates a multi-frame container.
// loop=0 means infinite playback (APNG-spec). Use loop=1 to play
// the fade-in once and then stop on the final frame.
//
// Each frame is an ImageHandle whose pixels get COPIED into the
// animation. The handle stays usable, so we clone the source per
// frame, brighten it to the desired opacity, push it in, and free
// the clone. The original source stays alive throughout.

const src = Engine.loadImage(INPUT);
const anim = Engine.animation(src.width, src.height, { loop: 0 });

const p = Engine.progress("Building fade-in", { total: FRAMES });
for (let i = 0; i < FRAMES; i++) {
  const opacity = i / (FRAMES - 1);
  const frame = src.clone().brightness(opacity);
  anim.addFrame(frame, { delay: DELAY_MS });
  frame.free();
  p.step(1, `frame ${i + 1}/${FRAMES} opacity=${opacity.toFixed(2)}`);
}
p.done(`✓ ${FRAMES} frames`);

// Hold the final fully-bright frame for 5 s so the loop has a clean
// rest beat — without this the animation flickers as it restarts.
anim.addFrame(src.clone(), { delay: 5000 });

// saveAPNG would also work; saveWebP lossy keeps the gallery asset
// small (~85 % smaller than APNG) at visually identical quality on
// non-dark content. See the QUALITY param note at the top of this
// file for why we default to 90 here.
const enc = Engine.progress("Encoding WebP");
anim.saveWebP(OUTPUT, { lossy: QUALITY < 100, quality: QUALITY });
enc.done("✓ saved");
anim.free();
src.free();

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