Build apps
Animation
Return several frames, time them, and keep the image small.
On this page
An animation is a list of frames. Return more than one in image, and the display plays them one
after another; the platform turns them into one animated image.

import { defineApp } from "@ledable/sdk/app";
import {
Circle,
Frame,
easeInOutQuad,
renderAnimation,
type FrameContext,
} from "@ledable/sdk/graphics";
const FPS = 25;
// One hop of all three dots; the display repeats it for as long as the
// entry is on screen, so the animation itself stays short and small.
const LOOP_MS = 1200;
const COLORS = ["#28d7c8", "#ff5fa2", "#f6cd6d"];
const HOP_PX = 8;
const DAY_MS = 24 * 60 * 60 * 1000;
/** 0 at rest, 1 at the top of the hop, for a phase anywhere in 0..1. */
function lift(phase: number): number {
const t = ((phase % 1) + 1) % 1;
// A dot hops in the first half of its phase and rests in the second.
// Easing both ways makes it slow down at the top, as a thrown ball does.
if (t >= 0.5) return 0;
return easeInOutQuad(t < 0.25 ? t * 4 : 2 - t * 4);
}
function dots(ctx: FrameContext) {
return (
<Frame width={ctx.width} height={ctx.height} background="#000000">
{COLORS.map((color, index) => (
<Circle
key={index}
x={18 + index * 11}
// Each dot runs a sixth of a loop behind the one before it.
y={19 - Math.round(lift(ctx.progress - index / 6) * HOP_PX)}
diameter={6}
fill={color}
/>
))}
</Frame>
);
}
function animation([width, height]: readonly [number, number]) {
const frameCount = Math.round((LOOP_MS / 1000) * FPS);
const options = { frameCount, fps: FPS, width, height };
const image = renderAnimation(dots, options);
return { image, frameMs: 1000 / FPS, behavior: "loop" as const };
}
export default defineApp({
settings: () => ({}),
// Nothing in it ever changes, so the display need not ask again for days.
render: (ctx) => ({
...animation(ctx.resolution),
softTtlMs: DAY_MS,
hardTtlMs: 2 * DAY_MS,
}),
preview: () => animation([64, 32]),
});Frames and their timing
Every frame in image must have the same size, the panel's. frameMs is how long each frame
shows, in whole milliseconds; it is 200 unless you set it, and a fraction makes the
render fail. Every frame shows for the same time: to hold one frame longer, put it in the list
more than once.
Pick a frame rate that divides 1000, such as 10, 20, 25 or 50 frames a second, so that
1000 / fps is a whole number of milliseconds. Every extra frame makes the image larger, so use
the lowest rate at which your motion still looks smooth.
Drawing frames from time
renderAnimation(draw, { frameCount, fps }) calls draw once per frame with a frame context and
returns the frames. The context holds:
frame, the frame's number from 0, andframeCount;fps, andtimeMs, the time since the first frame;progress, from 0 up to, but never reaching, 1;widthandheightof the frame: 64 and 32 unless you passwidthandheightin the options. Inrender, pass the size fromctx.resolution, as the example does.
Because progress stops one frame short of 1, the last frame leads straight back into the first,
and a loop drawn from progress has no visible seam: a frame where everything is back at its start
would show the start twice.
Coordinates are whole pixels, so round what you compute. The SDK has the helpers for computing
motion: lerp(a, b, t), clamp and clamp01, and the easings easeInQuad, easeOutQuad,
easeInOutQuad, easeInCubic, easeOutCubic and easeInOutCubic, which all take and return a
number from 0 to 1.
Scrolling
marquee from @ledable/sdk/app plans a scroll: give it the distance in pixels and how long the
scroll takes, and it returns the frameCount, the frameMs and offsetAt(frame), how far the
content has moved by that frame. The last frame reaches the full distance.
const width = measureText(font, message).width;
// Enter at the right edge and leave at the left: the text's width plus
// the panel's.
const motion = marquee({ distancePx: width + 64, durationMs: 8000 });
const image = Array.from({ length: motion.frameCount }, (_, frame) =>
renderFrame(
<Frame width={64} height={32}>
<Text
x={64 - motion.offsetAt(frame)}
y={16}
anchor="lm"
text={message}
font={font}
color="#ffffff"
/>
</Frame>,
),
);
return { image, frameMs: motion.frameMs };fps is 50 unless you pass one. A marquee never makes more than
600 frames: for a longer scroll it keeps the duration and shows each frame for
longer. The Quickstart has a complete app that scrolls a message.
How the display plays it
People choose how long each entry of their playlist shows. When that time is up, the display moves on to the next entry, wherever the animation is. Each time the entry comes back, its animation starts again from the first frame.
behavior in the render's result says what happens within that time:
loop, the default: the animation repeats until the time is up. Make one short cycle that loops, like the example, rather than a long one.one_shot: the animation plays once, and its last frame stays until the time is up.one_shot_advance: the animation plays once, and the display moves on as soon as it ends. The entry can end early this way, never late.
An animation longer than the entry's time is cut off. ctx.durationMs is that time in
milliseconds, as the display asks: use it when something must be seen to the end, such as a
message that scrolls past once. It can be 0 when the entry follows the display's default duration
rather than its own, so treat 0 as unknown and fall back to a length of your own.
ledable developer render asks with a duration of 5 seconds unless you pass --duration.
Keep it small
The display takes an image of at most 1 MiB. One it cannot take never replaces the picture it shows: until the entry's image expires the display keeps the last one, and after that it shows its loading indicator (see Caching and refresh).
Every frame is stored whole and compressed without loss, so an animation is about as large as all its frames together. A frame with large areas of one colour compresses well; noise and gradients do not. To stay small:
- loop a short cycle instead of rendering a long one;
- lower the frame rate;
- keep frames simple: flat colours and few details.
ledable developer render --out <file> prints the size of the image it wrote. It compresses with a
simpler encoder than LEDABLE's servers, so its files are usually larger than what a display
receives.
Rendering takes time too. Every frame is drawn in software, and the store refuses an upload whose preview or a render with default settings takes longer than 10 seconds (see Data and the network).