@singleton-sd/inkads-epaper-renderer
v1.4.1
Published
Converts advertiser artwork into display-ready e-paper framebuffers for InkAds — deterministic crop, dither, and pack for Waveshare 7.5″ B/W panels.
Readme
InkAds e-paper renderer
Shared TypeScript package that converts advertiser artwork into display-ready e-paper assets for InkAds.
The same implementation is intended for:
- browser preview (marketing demo and advertiser UI)
- backend asset processing
- deterministic fixtures used to validate firmware on Waveshare hardware
Image processing stays in this package / the cloud. ESP32 firmware consumes packed framebuffer bytes only.
Status
Proof of concept. The pipeline from upload to device-ready bytes is complete
for the Waveshare 7.5″ B/W target: ingest → crop/resize/zoom → dither → pack,
with golden fixtures pinning the output byte for byte. The public API below is
settled; validation against physical hardware (#8) and colour panels (#9)
remain open. Profile orientation packs rotated mounts; confirm against
hardware in #8.
Published to the public npm registry as
@singleton-sd/inkads-epaper-renderer.
Each release on main bumps the version, tags, updates the changelog, creates a
GitHub release, and publishes to npm.
Installing
pnpm add @singleton-sd/inkads-epaper-renderer
# or: npm install @singleton-sd/inkads-epaper-rendererThe browser entry point is the package root; server-only decode/encode lives at
/node:
import { renderMono, waveshare75BwProfile } from '@singleton-sd/inkads-epaper-renderer';
import { ingestImageToProfile } from '@singleton-sd/inkads-epaper-renderer/node';Pin a specific version in consumer package.json rather than a range while the
API is still settling ("1.1.1" not "^1.1.1").
Requirements
- Node.js 22.12 or newer
- pnpm 11.22
pnpm install
pnpm format:check
pnpm lint
pnpm typecheck
pnpm testLocal hooks
After pnpm install, Husky installs commit hooks. Once per clone you can also
confirm:
git config core.hooksPath .huskyHooks enforce Conventional Commits with a GitHub #issue ticket, TypeScript
filename conventions, and branch names of the form
<type>/<issue>-<kebab-title>.
Releases
Semantic versions are produced by release-it from Conventional Commits.
On every push to main (except an existing chore: Release … commit), GitHub
Actions runs pnpm release:ci. That bumps the version, updates CHANGELOG.md,
pushes a semver tag, creates a GitHub Release (notes include the matching npmjs
version link), and publishes to the public npm registry.
Distribution: public npm at @singleton-sd/inkads-epaper-renderer. Scoped
packages default to private on npm; publishConfig.access: public in
package.json overrides that.
Publishing auth: npm Trusted Publishing (OIDC). The release workflow requests a short-lived GitHub OIDC token; npm validates it against a trusted publisher configured on npmjs.com. No NPM_TOKEN or Azure Key Vault secret is used for publish.
One-time setup on npmjs.com (per package):
| Field | Value |
| -------------------- | ---------------------------- |
| Organization or user | singleton-sd |
| Repository | poc-inkads-epaper-renderer |
| Workflow filename | release.yml |
| Allowed actions | npm publish |
Package → Settings → Trusted publishing → GitHub Actions. If the package does not exist on npm yet, a maintainer may need one interactive publish with 2FA before trusted publishing can take over.
After the first successful OIDC publish, consider Package → Settings → Publishing access → Require two-factor authentication and disallow tokens so long-lived publish tokens cannot be used.
Requires release-it ≥ 19.0.4 (Octokit logger fix for GitHub Releases) and npm
CLI ≥ 11.5.1. The release workflow pins npm@11 (not unbounded @latest).
Do not set publishConfig.registry. release-it passes it as one CLI token
(--registry https://…); npm 12 then fails with EUNKNOWNCONFIG /
--//registry.npmjs.org. Keep only publishConfig.access: public — the
public registry is already the default.
Do not pass registry-url to actions/setup-node in the release job
either; that writes an auth .npmrc that short-circuits OIDC.
npm.skipChecks is required for Trusted Publishing: release-it's preflight
(npm whoami) expects a static token, but OIDC credentials only exist during
npm publish inside the Actions job. Locally there is no OIDC token, so
pnpm release:ci must not be run on a developer machine — it would either
fail at publish or (if somehow authenticated another way) cut a release outside
CI. Prefer the Actions workflow (workflow_dispatch or a push to main).
Locally:
pnpm release # dry-run only (no publish, no git push)Bootstrap / first package creation (interactive, with 2FA):
pnpm build && npm publish --access publicNever hand-edit package.json version.
Recovery: tag exists, GitHub Release missing
If CI pushed a semver tag but failed while creating the GitHub Release, do not
rerun pnpm release:ci — that can cut another empty version bump.
Create the missing Release from the existing tag instead:
# Example for tag 0.0.3
gh release create 0.0.3 --title "v0.0.3" --generate-notes --verify-tagVerify:
gh release view 0.0.3
gh release listThe Release page should list the tag under https://github.com/singleton-sd/poc-inkads-epaper-renderer/releases.
The pipeline end to end
Four stages take an advertiser upload to bytes the panel can display. Each is a separate export, so a caller can stop early (to show a preview) or re-run one stage (to re-dither without re-decoding).
import {
normaliseToProfile,
packMonoBitmap,
renderMono,
toPreviewImage,
waveshare75BwProfile as profile,
} from '@singleton-sd/inkads-epaper-renderer';
import { decodeImage } from '@singleton-sd/inkads-epaper-renderer/node';
const decoded = decodeImage(uploadBytes); // 1. upload → RGB pixels
const framed = normaliseToProfile(decoded, { profile }); // 2. → 800×480
const bitmap = renderMono(framed, { mode: 'atkinson' }); // 3. → 1-bit
const packed = packMonoBitmap(bitmap, { profile }); // 4. → 48,000 bytes
packed.bytes; // send to the device
packed.metadata.checksum; // CRC-32, so firmware can verify the download
toPreviewImage(packed, profile); // what the panel will actually showEvery stage is deterministic: the same input and options always produce byte-identical output, on any platform. That is what lets a browser preview be trusted as an exact prediction of the device result, and it is enforced by golden checksums over a set of representative creatives rather than by convention.
Behaviour is driven by the profile, never by loose width/height arguments, so adding a panel cannot silently change how an existing one renders.
Display profiles
Each hardware panel is a display profile with a stable id, fixed resolution, and expected packed framebuffer size. The renderer pipeline takes a profile — not arbitrary width/height — so cloud, preview, and firmware stay aligned.
| Field | waveshare-7.5-bw |
| ----------- | ------------------ |
| Panel | Waveshare 7.5″ B/W |
| Resolution | 800×480 (5:3) |
| Depth | 1 bit per pixel |
| Packed size | 48,000 bytes |
import { getDisplayProfile, listDisplayProfiles } from '@singleton-sd/inkads-epaper-renderer';
import { ingestImageToProfile } from '@singleton-sd/inkads-epaper-renderer/node';
listDisplayProfiles(); // [{ id: 'waveshare-7.5-bw', ... }, ...]
const profile = getDisplayProfile('waveshare-7.5-bw');
const rgb = ingestImageToProfile(pngOrJpegBytes, {
profile,
crop: { x: 0.5, y: 0.5 }, // optional cover-fit position
});
// rgb.width === 800, rgb.height === 480Monochrome modes (threshold, floyd-steinberg, atkinson) convert profile
RGB to a 1-bit bitmap (0 black / 1 white). Prefer threshold for text,
logos, and QR; use Atkinson for UI/illustration contrast; Floyd–Steinberg for
fullest grey simulation. Future panels (including colour, issue #9) add new ids
to the registry.
Packed framebuffer and preview
import {
packMonoBitmap,
renderMono,
toPreviewImage,
waveshare75BwProfile as profile,
} from '@singleton-sd/inkads-epaper-renderer';
import { ingestImageToProfile } from '@singleton-sd/inkads-epaper-renderer/node';
const rgb = ingestImageToProfile(uploadBytes, { profile });
const bitmap = renderMono(rgb, { mode: 'atkinson' });
const packed = packMonoBitmap(bitmap, { profile });
packed.bytes.length; // 48000 — send these bytes to the device
packed.metadata; // profileId, rendererVersion, mode, byteLength, checksum, …
const preview = toPreviewImage(packed, profile);
canvasContext.putImageData(new ImageData(preview.data, preview.width), 0, 0);The packed layout is the firmware contract: row-major, 8 pixels per byte, MSB
is the leftmost pixel, and with polarity: 'normal' a set bit is a dark pixel.
checksum is CRC-32 (IEEE) over the packed bytes, so firmware can verify a
download with the same cheap algorithm. Preview pixels are expanded from the
packed bytes, not the upload, so what you see is what the panel renders.
When profile.orientation is not native, packing rotates the logical
width × height artwork into the device row layout (90° / 270° swap the
packed stride; byte length stays the same on 800×480). Preview returns the
device layout size — 480×800 for a portrait mount — so marketing UI can show
the panel chrome without a CSS transform. Metadata still records the logical
profile dimensions plus orientation for firmware.
Framing: crop, zoom, and letterbox
By default the image is cover-fit: scaled by max(800 / width, 480 / height)
so it fills the panel, with the overflow cropped. Aspect ratio is never
distorted, but content is discarded — a tall portrait can lose most of its
height. crop slides that fixed window; sourceRect sets the window itself,
which is what zooming is.
Framing UIs usually only send zoom, optional pan centre, and
rotation. Pass those on normaliseToProfile and the renderer builds (and
clamps) the sourceRect:
const framed = normaliseToProfile(decoded, {
profile,
rotation: 90,
zoom: 2, // 1 = cover-fit; >1 zooms in; <1 letterboxes
// centerX / centerY optional — default to image mid-point after rotation
});Helpers (defaultFraming, sourceRectFromFraming, clampFraming,
framingPanRoom, …) remain available when a consumer needs the math without
running the full pipeline. Gesture / button chrome stays in the consumer.
clampFraming keeps a cropped window inside the artwork, and when zoomed out
(letterboxed) lets the image sit anywhere as long as it stays fully inside the
panel — use framingPanRoom to disable pan arrows at the edge.
Or pass sourceRect directly when you already have an explicit region:
const framed = normaliseToProfile(decoded, {
profile,
// Region of the source to show, in source pixels.
sourceRect: { x: 120, y: 80, width: 900, height: 540 },
});
framed.sourceRect; // the region actually used, after aspect correctionThe rectangle may extend beyond the image on any side. That is how you zoom out
past cover-fit, and the uncovered area is filled with background (white by
default), which is how you letterbox instead of crop:
// Fit an entire 1000×2000 portrait onto the panel, bars either side.
normaliseToProfile(decoded, {
profile,
sourceRect: { x: -1166, y: 0, width: 3333, height: 2000 },
background: { r: 255, g: 255, b: 255 },
});If the rectangle's aspect ratio does not match the panel, it is grown about
its centre until it does, never squashed: everything the user framed stays
visible, and the artwork is never distorted. sourceRect on the result reports
the corrected region, so a framing UI can draw handles matching the real output
and warn when most of an upload is being discarded.
crop and sourceRect are mutually exclusive, since a rectangle already
carries its own position.
Optional rotation (0 | 90 | 180 | 270, default 0) turns the decoded
source clockwise before crop / sourceRect, so a sideways upload can be
framed upright. 90° and 270° swap the source width and height.
normaliseToProfile(decoded, { profile, rotation: 90, crop: { x: 0.5, y: 0.5 } });Browser and Node
The package has two entry points, so the browser never pays for a bundled image codec:
| Entry | Runs in | Contains |
| ------------------------------------------- | ---------------- | --------------------------------------------------------- |
| @singleton-sd/inkads-epaper-renderer | Browser and Node | Profiles, crop/resize, dither, pack, preview |
| @singleton-sd/inkads-epaper-renderer/node | Node only | decodeImage, ingestImageToProfile, encodePreviewPng |
The root entry point imports no Node built-ins, so the full crop → dither → pack → preview pipeline can re-run live in the browser as the user frames their creative. A test walks the import graph of each entry point and fails if a Node built-in or codec reaches the root one.
Only turning an uploaded file into pixels differs by platform. In the browser
the platform already has a decoder, so use it and pass the canvas pixels to
fromRgbaImageData:
import {
fromRgbaImageData,
normaliseToProfile,
waveshare75BwProfile as profile,
} from '@singleton-sd/inkads-epaper-renderer';
const bitmap = await createImageBitmap(file);
const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
const context = canvas.getContext('2d')!;
context.drawImage(bitmap, 0, 0);
const decoded = fromRgbaImageData(context.getImageData(0, 0, bitmap.width, bitmap.height));
const rgb = normaliseToProfile(decoded, { profile, crop: { x: 0.5, y: 0.5 } });encodePreviewPng is server-only for the same reason: in the browser, draw the
PreviewImage to a canvas and use canvas.toBlob() when you need a file.
API reference
Everything below is public and covered by semver. Anything not listed is internal and may change in a patch release; a test pins both lists, so an internal helper cannot leak out unnoticed.
Pipeline
| Export | Entry | Purpose |
| -------------------------------------------------------------------------------------------------- | ------- | -------------------------------------------------------------- |
| decodeImage | /node | PNG/JPEG bytes → RGB, with limits applied to untrusted uploads |
| fromRgbaImageData | root | Canvas RGBA → RGB, the browser's way in |
| normaliseToProfile | root | Crop, zoom, and resize to the profile |
| coverWindowSize / defaultFraming / sourceRectFromFraming / clampFraming / framingPanRoom | root | Centre/zoom framing helpers → sourceRect; pan-room for UI |
| rotatedImageSize / nextSourceRotation | root | Source rotation size + 90° step helper |
| ingestImageToProfile | /node | decodeImage + normaliseToProfile in one call |
| renderMono | root | RGB → 1-bit bitmap via threshold or dithering |
| packMonoBitmap | root | Bitmap → device-ready framebuffer plus metadata |
| toPreviewImage | root | Framebuffer → RGBA for a canvas |
| encodePreviewPng | /node | Preview → PNG file bytes |
| crc32Hex | root | The checksum firmware verifies against |
Profiles
| Export | Purpose |
| --------------------------------------------------------------- | ---------------------------------- |
| waveshare75BwProfile, WAVESHARE_7_5_BW_ID | The only panel currently supported |
| getDisplayProfile, hasDisplayProfile, listDisplayProfiles | Registry lookup |
| defineDisplayProfile | Define a custom panel |
| computePackedByteLength, assertAspectRatioMatchesDimensions | Helpers for defining one |
Errors, limits, and metadata
| Export | Purpose |
| ------------------------------- | ------------------------------------------- |
| ImageIngestError | Decode, limit, crop, and rectangle failures |
| MonoRenderError | Invalid mode, threshold, or buffer shape |
| FramebufferPackError | Profile mismatch or unsupported packing |
| DisplayProfileValidationError | Invalid profile definition |
| DEFAULT_DECODE_LIMITS | Upload ceilings, overridable per call |
| RENDERER_VERSION | Recorded in framebuffer metadata |
Each error carries a machine-readable code, so callers can branch on the
cause — telling an advertiser their file is too large versus corrupt — rather
than matching on message text.
Exported types mirror these: DisplayProfile, DisplayProfileId,
DisplayProfileInput, AspectRatio, DisplayOrientation, DisplayPolarity,
PixelPacking, DecodedImage, ProfileRgbBuffer, NormaliseToProfileOptions,
CropPosition, SourceRect, RgbColour, SourceRotation, ImageSize,
FramingState, FramingProfileSize, DecodeLimits, FromRgbaLimits,
RgbaImageData, MonoBitmap, MonoRenderMode, MonoSource,
RenderMonoOptions, PackedFramebuffer, FramebufferMetadata, PackSource,
PackMonoBitmapOptions, PackErrorCode, and PreviewImage. The /node entry
adds DecodeImageOptions.
Related repositories
poc-inkads-marketing— public site / interactive preview consumerpoc-inkads-firmware-display-device— ESP32 device firmwarepoc-inkads-assets— brand SVG/PNG masters (not this package)
Architecture reference
Follow repository conventions from
poc-plattform-kit and
Singleton SD skills (repo-init, git-conventions, isolated-worktree) where
applicable.
License
Proprietary — Singleton SD. See LICENSE. The package is public on
npm for installability; that does not grant an open-source license. Do not
commit secrets or commercially sensitive material.
