Commands, settings, SDK functions, anything in the docs.

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.

The pixel-icon example on a LEDABLE panel
pixel-icon/index.tsx
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

TSX
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) draws image with its top left at x, 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:

pixel-icon/convert-art.mjs
// 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`);
Terminal
node convert-art.mjs assets/cup.webp art/cup.png
node convert-art.mjs assets/steam.webp art/steam-*.png --frame-ms 250

Keep 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.

ledable.json
{
  "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.