From c67c46748656df71b31225d672dd6988454957f1 Mon Sep 17 00:00:00 2001 From: flop Date: Wed, 13 May 2026 13:40:50 +0200 Subject: [PATCH] feat: file encoder schemas --- ts/src/index.ts | 4 + ts/src/library.ts | 265 ++++++++++++++++++++++++++++++---------------- 2 files changed, 178 insertions(+), 91 deletions(-) diff --git a/ts/src/index.ts b/ts/src/index.ts index eaaeec7..e9327d5 100644 --- a/ts/src/index.ts +++ b/ts/src/index.ts @@ -37,6 +37,10 @@ export type { MonoDisplayFileDescriptor, DisplayElement, Image2DElement, + AnimationFrame, + AnimationElement, + TextElement, + ScrollTextElement, // Union type for all display content variants DisplayContent, diff --git a/ts/src/library.ts b/ts/src/library.ts index 6f19119..b80fad7 100644 --- a/ts/src/library.ts +++ b/ts/src/library.ts @@ -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; } }