feat: file encoder schemas

pull/1/head
flop 4 weeks ago
parent d565345a3f
commit c67c467486
  1. 4
      ts/src/index.ts
  2. 265
      ts/src/library.ts

@ -37,6 +37,10 @@ export type {
MonoDisplayFileDescriptor,
DisplayElement,
Image2DElement,
AnimationFrame,
AnimationElement,
TextElement,
ScrollTextElement,
// Union type for all display content variants
DisplayContent,

@ -90,45 +90,78 @@ export type DisplayContent = StaticFrame | Animation | TextDisplay | ScrollText;
// MonoDisplayFile — JSON-descriptor → binary builder
// ---------------------------------------------------------------------------
/**
* Element types that can appear in a display descriptor.
* PLAN: extend with Text, ScrollText, Animation once section spec is complete.
*/
export enum ElementType {
/** A 1bpp rectangular bitmap placed at an (xoffset, yoffset) position */
Image2D = "Image2D",
// PLAN: Text = "Text",
// PLAN: ScrollText = "ScrollText",
// PLAN: Animation = "Animation",
/** Positioned 1bpp bitmap. Composited with OR-blend onto the display canvas. */
Image2D = "Image2D",
/** Multi-frame bitmap animation. Each frame has its own pixel data and duration. */
Animation = "Animation",
/** Static text rendered with the built-in bitmap font (stub until font spec lands). */
Text = "Text",
/** Horizontally scrolling text ticker. */
ScrollText = "ScrollText",
}
/** A positioned 1bpp image placed on the display canvas */
/** Positioned 1bpp image, composited onto the display canvas */
export interface Image2DElement {
type: ElementType.Image2D;
/** Raw pixel data — 1 byte per pixel (0=off 1=on), row-major */
/** 1 byte per pixel (0=off 1=on), row-major */
pixels: Uint8Array;
width: number;
height: number;
/** Horizontal offset from display left edge. Default 0. */
/** Pixel offset from left edge. Default 0. */
xoffset?: number;
/** Vertical offset from display top edge. Default 0. */
/** Pixel offset from top edge. Default 0. */
yoffset?: number;
}
// Union for future element types
export type DisplayElement = Image2DElement;
// PLAN: | TextElement | ScrollTextElement | AnimationElement
/** One frame in an AnimationElement */
export interface AnimationFrame {
pixels: Uint8Array;
durationMs: number;
}
/** Multi-frame animation section. width/height shared by all frames. */
export interface AnimationElement {
type: ElementType.Animation;
width: number;
height: number;
frames: AnimationFrame[];
}
/** Static text rendered by the driver's bitmap font */
export interface TextElement {
type: ElementType.Text;
text: string;
/** 0=left 1=center 2=right. Default 0. */
align?: 0 | 1 | 2;
/** Font index (reserved). Default 0. */
fontId?: number;
}
/** Scrolling text ticker */
export interface ScrollTextElement {
type: ElementType.ScrollText;
text: string;
/** Pixels per second. Default 60. */
speedPps?: number;
/** 0=scroll left (→off left edge) 1=scroll right. Default 0. */
direction?: 0 | 1;
}
export type DisplayElement =
| Image2DElement
| AnimationElement
| TextElement
| ScrollTextElement;
/**
* JSON descriptor passed to MonoDisplayFile.
* JSON descriptor for MonoDisplayFile.
*
* elements_always rendered on every frame regardless of state.
* PLAN: elements_conditional rendered only when a named condition is active
* (e.g. for state-machine-driven displays, ticker overrides, alerts).
* elements_always rendered on every frame regardless of state.
* PLAN: elements_conditional state-machine-driven visibility (alerts, overrides).
*
* width / height display canvas size in pixels.
* If omitted, inferred as the bounding box of all elements.
* Explicit values are required if elements don't fill the display.
* width / height display canvas in pixels. Omit to infer from Image2D element bounds.
* Text/ScrollText/Animation ignore canvas size (section has no position).
*/
export interface MonoDisplayFileDescriptor {
width?: number;
@ -139,19 +172,13 @@ export interface MonoDisplayFileDescriptor {
/**
* Builds a binary .bin file from a JSON descriptor.
*
* Usage:
* ```ts
* const file = new MonoDisplayFile({
* width: 160, height: 80,
* elements_always: [
* { type: ElementType.Image2D, pixels: myUint8Array, width: 120, height: 80, xoffset: 20 }
* ]
* });
* driver.load(() => Promise.resolve(file.toBuffer()));
* ```
* Dispatch rules (first element_always wins for section type):
* Image2D only static section (0x01), composited with OR-blend
* Animation animation section (0x02)
* Text text section (0x03)
* ScrollText scrolltext section (0x04)
*
* PLAN: toBuffer() currently composites all elements_always into one static frame.
* When animated elements are added, it will produce an animation section instead.
* PLAN: multiple sections per file once spec defines multi-section rendering.
*/
export class MonoDisplayFile {
private desc: MonoDisplayFileDescriptor;
@ -160,46 +187,120 @@ export class MonoDisplayFile {
this.desc = descriptor;
}
/**
* Serialize to a valid .bin ArrayBuffer that MonoDisplayParser can read.
* Composites all elements_always onto a single static frame.
*/
toBuffer(): ArrayBuffer {
const elements = this.desc.elements_always ?? [];
// Dispatch on first element's type (or default to blank static)
const first = elements[0];
if (!first || first.type === ElementType.Image2D) {
return this.#encodeStatic();
}
switch (first.type) {
case ElementType.Animation: return this.#encodeAnimation(first);
case ElementType.Text: return this.#encodeText(first);
case ElementType.ScrollText: return this.#encodeScrollText(first);
}
}
// --- static (section 0x01) ---
#encodeStatic(): ArrayBuffer {
const { width, height } = this.#resolveSize();
const canvas = this.#composite(width, height);
const pixels = this.#composite(width, height);
return this.#wrapSection(0x01, (view, off) => {
view.setUint16(off, width, true); off += 2;
view.setUint16(off, height, true); off += 2;
new Uint8Array(view.buffer, off).set(pixels);
return off + pixels.byteLength;
}, 2 + 2 + width * height);
}
// --- animation (section 0x02) ---
#encodeAnimation(el: AnimationElement): ArrayBuffer {
const pixelBytes = el.width * el.height;
// width(2) + height(2) + frameCount(2) + frames*(durationMs(4) + pixels(w*h))
const dataSize = 2 + 2 + 2 + el.frames.length * (4 + pixelBytes);
return this.#wrapSection(0x02, (view, off) => {
view.setUint16(off, el.width, true); off += 2;
view.setUint16(off, el.height, true); off += 2;
view.setUint16(off, el.frames.length, true); off += 2;
for (const frame of el.frames) {
view.setUint32(off, frame.durationMs, true); off += 4;
new Uint8Array(view.buffer, off).set(frame.pixels);
off += pixelBytes;
}
return off;
}, dataSize);
}
// Encode as static section (0x01): width(u16) height(u16) pixels[w*h]
const sectionDataSize = 2 + 2 + width * height;
const totalSize = 12 + 4 + sectionDataSize; // fileHeader + sectionHeader + data
// --- text (section 0x03) ---
#encodeText(el: TextElement): ArrayBuffer {
const enc = new TextEncoder().encode(el.text);
// textLen(2) + utf8 + fontId(1) + align(1)
const dataSize = 2 + enc.byteLength + 1 + 1;
return this.#wrapSection(0x03, (view, off) => {
view.setUint16(off, enc.byteLength, true); off += 2;
new Uint8Array(view.buffer, off).set(enc); off += enc.byteLength;
view.setUint8(off, el.fontId ?? 0); off += 1;
view.setUint8(off, el.align ?? 0); off += 1;
return off;
}, dataSize);
}
// --- scrolltext (section 0x04) ---
#encodeScrollText(el: ScrollTextElement): ArrayBuffer {
const enc = new TextEncoder().encode(el.text);
// textLen(2) + utf8 + speedPps(2) + direction(1)
const dataSize = 2 + enc.byteLength + 2 + 1;
return this.#wrapSection(0x04, (view, off) => {
view.setUint16(off, enc.byteLength, true); off += 2;
new Uint8Array(view.buffer, off).set(enc); off += enc.byteLength;
view.setUint16(off, el.speedPps ?? 60, true); off += 2;
view.setUint8(off, el.direction ?? 0); off += 1;
return off;
}, dataSize);
}
const buf = new ArrayBuffer(totalSize);
// --- shared file/section envelope ---
/**
* Allocates a buffer with a 12-byte file header + 4-byte section header +
* sectionDataSize bytes, then calls write(view, dataOffset) to fill data.
*/
#wrapSection(
sectionType: number,
write: (view: DataView, dataOffset: number) => number,
sectionDataSize: number,
): ArrayBuffer {
const buf = new ArrayBuffer(12 + 4 + sectionDataSize);
const view = new DataView(buf);
let off = 0;
// File header
view.setUint32(off, MAGIC, true); off += 4; // magic
view.setUint32(off, 1, true); off += 4; // version = 1
view.setUint16(off, 1, true); off += 2; // numSections = 1
view.setUint16(off, 0, true); off += 2; // reserved = 0
// Section header: type(1) + size uint24(3)
view.setUint8(off, 0x01); off += 1; // static section
view.setUint8(off, sectionDataSize & 0xFF);
view.setUint32(off, MAGIC, true); off += 4;
view.setUint32(off, 1, true); off += 4; // version
view.setUint16(off, 1, true); off += 2; // numSections
view.setUint16(off, 0, true); off += 2; // reserved
// Section header: type(1) + size uint24 LE(3)
view.setUint8(off, sectionType); off += 1;
view.setUint8(off, sectionDataSize & 0xFF);
view.setUint8(off + 1, (sectionDataSize >> 8) & 0xFF);
view.setUint8(off + 2, (sectionDataSize >> 16) & 0xFF);
off += 3;
// Section data
view.setUint16(off, width, true); off += 2;
view.setUint16(off, height, true); off += 2;
new Uint8Array(buf, off).set(canvas);
write(view, off);
return buf;
}
/** Determine display dimensions: explicit > inferred from element bounding box > defaults */
// --- size inference for Image2D compositing ---
#resolveSize(): { width: number; height: number } {
const elements = this.desc.elements_always ?? [];
const elements = (this.desc.elements_always ?? [])
.filter((el): el is Image2DElement => el.type === ElementType.Image2D);
let width = this.desc.width;
let height = this.desc.height;
@ -217,42 +318,24 @@ export class MonoDisplayFile {
return { width, height };
}
/** Composite all elements_always onto a blank canvas (0=off, 1=on) */
#composite(width: number, height: number): Uint8Array {
const canvas = new Uint8Array(width * height); // starts all-off
const canvas = new Uint8Array(width * height);
for (const el of this.desc.elements_always ?? []) {
this.#blitElement(el, canvas, width, height);
}
return canvas;
}
/** Blit one element onto the canvas, clipping to display bounds */
#blitElement(el: DisplayElement, canvas: Uint8Array, canvasW: number, canvasH: number): void {
if (el.type !== ElementType.Image2D) {
// PLAN: dispatch other element types here
throw new Error(`MonoDisplayFile: element type "${el.type}" not yet implemented`);
}
const ox = el.xoffset ?? 0;
const oy = el.yoffset ?? 0;
for (let row = 0; row < el.height; row++) {
const destY = oy + row;
if (destY < 0 || destY >= canvasH) continue; // clip top/bottom
for (let col = 0; col < el.width; col++) {
const destX = ox + col;
if (destX < 0 || destX >= canvasW) continue; // clip left/right
const srcIdx = row * el.width + col;
const destIdx = destY * canvasW + destX;
// OR-blend: any element can turn a pixel on; none can turn it off
// PLAN: add blend mode (OR / XOR / replace) to Image2DElement once spec covers it
if (el.pixels[srcIdx]) canvas[destIdx] = 1;
if (el.type !== ElementType.Image2D) continue;
const ox = el.xoffset ?? 0;
const oy = el.yoffset ?? 0;
for (let row = 0; row < el.height; row++) {
const destY = oy + row;
if (destY < 0 || destY >= height) continue;
for (let col = 0; col < el.width; col++) {
const destX = ox + col;
if (destX < 0 || destX >= width) continue;
// OR-blend — PLAN: add blend mode enum once spec covers it
if (el.pixels[row * el.width + col]) canvas[destY * width + destX] = 1;
}
}
}
return canvas;
}
}

Loading…
Cancel
Save