Build apps
Images and assets
Ship images and animations with your app and draw them.
On this page
Artwork is a file in your project, never a long list of pixels in code. You import the file, the CLI publishes it with each version of your app, and your app reads it while it draws.

import { defineApp, type AppAssets } from "@ledable/sdk/app";
import {
Frame,
Text,
cloneFramebuffer,
compositeFramebuffer,
renderFrame,
type Framebuffer,
} from "@ledable/sdk/graphics";
import { PIXOLLETTA, loadFont } from "@ledable/sdk/fonts";
// An import gives a reference to the file, not its bytes; the file is
// published with the version and read through ctx.assets.
import cupArt from "./assets/cup.webp";
import steamArt from "./assets/steam.webp";
async function coffeeBreak(assets: AppAssets) {
const font = await loadFont(assets, PIXOLLETTA);
const cup: Framebuffer = await assets.image(cupArt);
const steam = await assets.frames(steamArt);
// The text is the same in every frame, so it is drawn once.
const text = renderFrame(
<Frame width={64} height={32} background="#000000">
<Text
x={44}
y={16}
anchor="mm"
text={"Coffee\nbreak"}
font={font}
color="#f6cd6d"
lineSpacing={2}
align="center"
/>
</Frame>,
);
const image = steam.map((puff) => {
// A copy of the text frame, with the images drawn over it. Their
// transparent pixels leave what is underneath as it is.
const frame = cloneFramebuffer(text);
compositeFramebuffer(frame, cup, 6, 13);
compositeFramebuffer(frame, puff, 8, 7);
return frame;
});
return { image, frameMs: 250 };
}
export default defineApp({
settings: () => ({}),
render: (ctx) => coffeeBreak(ctx.assets),
preview: (ctx) => coffeeBreak(ctx.assets),
});Import a file
import cupArt from "./assets/cup.webp";An import of a .webp file gives you an AssetRef, a small object that names the file: its
path inside your project, here assets/cup.webp. The bytes are not part of your code. When you
build or upload, the CLI collects every file your code imports and publishes it next to the code.
Apart from the store icon and screenshots (below), a file you do not import is not published.
The fonts of @ledable/sdk/fonts are assets in the same way.
An asset must be a .webp image or a .ledfont font inside your project's folder. Its path,
assets/cup.webp above, follows these rules:
- every folder and file name starts with a letter or a digit, followed by letters, digits,
.,_or-; - the whole path is at most 256 characters;
- the top folder is not
sdk, which the platform keeps for the SDK's own files.
Read it while you draw
ctx.assets reads the files of your app's version. It is in the context of render, preview
and settings.
ctx.assets.image(ref)returns the image as a framebuffer; for an animation, its first frame.ctx.assets.frames(ref)returns every frame of an animation, in order; a still image gives one.ctx.assets.bytes(ref)returns the file's bytes as they are.
The platform keeps what it has read and decoded in memory, so reading an asset on every render is cheap after the first time.
Draw an image
There is no image element: an image is a framebuffer, and you draw it onto a frame you rendered.
compositeFramebuffer(frame, image, x, y)drawsimagewith its top left atx,y, blending its transparent and half-transparent pixels over what is there. Whatever falls outside the frame is cut off.blit(frame, image, x, y)copies the pixels instead, transparent ones included.cloneFramebuffer(frame)copies a frame, so you can draw the same background under several images, as the example does for each puff of steam.
To put text or shapes over an image, render them in a frame with a transparent background, such as
#00000000, and composite that frame over the image.
containRgb(image, maxWidth, maxHeight) scales an image to fit a box and keeps its
proportions. It drops transparency, so use it for photos and album art rather than for pixel art,
which you should draw at the size it is shown.
WebP files
Assets are WebP files. Save them lossless, so the panel shows every pixel as you drew it.
An animated WebP is accepted only when every frame is a complete picture: each frame covers the
whole image, and no frame is blended over the one before. Many tools that write animated WebP,
sharp among them, save space by storing only the part of a frame that changed, and the store
refuses those files.
@ledable/sdk/webp writes exactly the accepted kind, losslessly: encodeWebp(image) makes a
still image, and encodeAnimatedWebp(frames, { frameMs, loop }) an animation.
This script, from the example, turns PNG files into an asset with them. It reads the PNGs with
sharp, which you add to your project with
npm install --save-dev sharp:
// Turns PNG artwork into a WebP asset the platform accepts: one PNG makes
// a still image, several make an animation of full, unblended frames.
//
// node convert-art.mjs <out.webp> <frame.png>... [--frame-ms <ms>]
//
// Needs sharp to read the PNGs: npm install --save-dev sharp
import { writeFile } from "node:fs/promises";
import sharp from "sharp";
import { encodeAnimatedWebp, encodeWebp } from "@ledable/sdk/webp";
const args = process.argv.slice(2);
const flag = args.indexOf("--frame-ms");
const frameMs = flag === -1 ? 100 : Number(args[flag + 1]);
const [output, ...inputs] = flag === -1 ? args : args.slice(0, flag);
const usable = output !== undefined && inputs.length > 0;
if (!usable || !Number.isInteger(frameMs)) {
console.error("usage: convert-art.mjs <out.webp> <png>... [--frame-ms <ms>]");
process.exit(1);
}
async function readPng(file) {
// Raw RGBA, alpha added where the PNG has none, exactly as drawn.
const { data, info } = await sharp(file)
.ensureAlpha()
.raw()
.toBuffer({ resolveWithObject: true });
return { width: info.width, height: info.height, data };
}
const frames = await Promise.all(inputs.map(readPng));
// Both encoders write lossless WebP: every pixel comes back as drawn.
const webp = frames.length === 1
? await encodeWebp(frames[0])
: await encodeAnimatedWebp(frames, { frameMs, loop: true });
await writeFile(output, webp);
console.log(`${output}: ${frames.length} frame(s), ${webp.length} bytes`);node convert-art.mjs assets/cup.webp art/cup.png
node convert-art.mjs assets/steam.webp art/steam-*.png --frame-ms 250Keep the PNGs in your project as the source of your artwork; only the WebP files you import are published.
Size limits
The store checks every upload. Each asset may be at most 4 MiB, all assets of a version together
at most 8 MiB, and the bundled code, your own with the parts of the SDK it uses, at most 1 MiB.
The fonts you import count as assets: FUSION, the font with Chinese, Japanese and Korean
characters, is about 370 KB, the other fonts at most about 20 KB each. An image larger than 2048
pixels on a side cannot be read while your app draws.
Images from the network
Images your app downloads, such as album covers, are not assets. ctx.images.decode(bytes) decodes
a PNG or JPEG of at most 2 MiB and 2048 pixels on a side into a framebuffer, which you then scale
with containRgb and draw like any other image. How to fetch them is in
Data and the network.
Store icon and screenshots
Your app's icon and screenshots in the store are assets too. Name them in ledable.json with
icon and screenshots,
each a WebP file in your project; an app has at most 6 screenshots.
{
"icon": { "path": "assets/icon.webp" },
"screenshots": [{ "path": "assets/screenshot-1.webp" }]
}These are the only files of your app anyone can download. All other assets, fonts included, are read only by your app.