|
|
|
|
@ -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 */ |
|
|
|
|
/** Positioned 1bpp bitmap. Composited with OR-blend onto the display canvas. */ |
|
|
|
|
Image2D = "Image2D", |
|
|
|
|
// PLAN: Text = "Text",
|
|
|
|
|
// PLAN: ScrollText = "ScrollText",
|
|
|
|
|
// PLAN: Animation = "Animation",
|
|
|
|
|
/** 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). |
|
|
|
|
* 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 { width, height } = this.#resolveSize(); |
|
|
|
|
const canvas = this.#composite(width, height); |
|
|
|
|
const elements = this.desc.elements_always ?? []; |
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
// 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); |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
const buf = new ArrayBuffer(totalSize); |
|
|
|
|
// --- static (section 0x01) ---
|
|
|
|
|
|
|
|
|
|
#encodeStatic(): ArrayBuffer { |
|
|
|
|
const { width, height } = this.#resolveSize(); |
|
|
|
|
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); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// --- 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); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
// --- 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
|
|
|
|
|
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(3)
|
|
|
|
|
view.setUint8(off, 0x01); off += 1; // static section
|
|
|
|
|
// 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`); |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
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 >= canvasH) continue; // clip top/bottom
|
|
|
|
|
|
|
|
|
|
if (destY < 0 || destY >= height) continue; |
|
|
|
|
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 (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; |
|
|
|
|
} |
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|