From c1ab9a4bb53d3592d70f58be0340ff0d7f512fc1 Mon Sep 17 00:00:00 2001 From: Corey Johnson Date: Wed, 21 Jan 2026 16:47:23 -0800 Subject: [PATCH] so many things --- .gitignore | 4 + sounds/stalling/hum.wav | Bin 0 -> 54022 bytes src/agent/personality.md | 63 +++ src/phone.ts | 105 +++-- src/sip.ts | 11 +- src/utils/twilio.ts | 50 -- src/vesta/README.md | 55 +++ src/vesta/cli.ts | 107 +++++ src/vesta/draw.test.ts | 258 ++++++++++ src/vesta/draw.ts | 366 +++++++++++++++ src/vesta/examples.json | 991 +++++++++++++++++++++++++++++++++++++++ src/vesta/generate.ts | 55 +++ src/vesta/prompt.md | 27 ++ src/vesta/render.ts | 151 ++++++ src/vesta/vestaboard.ts | 26 + 15 files changed, 2186 insertions(+), 83 deletions(-) create mode 100644 sounds/stalling/hum.wav create mode 100644 src/agent/personality.md delete mode 100644 src/utils/twilio.ts create mode 100644 src/vesta/README.md create mode 100644 src/vesta/cli.ts create mode 100644 src/vesta/draw.test.ts create mode 100644 src/vesta/draw.ts create mode 100644 src/vesta/examples.json create mode 100644 src/vesta/generate.ts create mode 100644 src/vesta/prompt.md create mode 100644 src/vesta/render.ts create mode 100644 src/vesta/vestaboard.ts diff --git a/.gitignore b/.gitignore index c920059..bedc8d7 100644 --- a/.gitignore +++ b/.gitignore @@ -34,3 +34,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .DS_Store .claude.worktrees/ +.worktrees/ + +docs/learning/ +docs/plans/ \ No newline at end of file diff --git a/sounds/stalling/hum.wav b/sounds/stalling/hum.wav new file mode 100644 index 0000000000000000000000000000000000000000..2293611b7e386b26e1a01c65f9a9a541253fc1ce GIT binary patch literal 54022 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 void @@ -33,6 +32,7 @@ type PhoneContext = { ringer: GPIO.Output agentId: string agentKey: string + dialFailureReason?: string } type PhoneService = Service @@ -121,7 +121,7 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer baresip.dialFailed.on(({ reason }) => { log.error("🐻 dial failed:", reason) - phoneService.send({ type: "dial-failed" } as any) + phoneService.send({ type: "dial-failed", reason } as any) }) baresip.connect().catch((error) => { @@ -130,16 +130,7 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer }) baresip.error.on(async ({ message, statusCode, reason }) => { - let errorMessage = message - - if (statusCode) { - const twilioInfo = await getTwilioAccountInfo() - if (twilioInfo && twilioInfo.status !== "active") { - errorMessage = formatTwilioError(twilioInfo) - } else { - errorMessage = `Registration failed: ${statusCode} ${reason}` - } - } + const errorMessage = statusCode ? `Registration failed: ${statusCode} ${reason}` : message log.error("🐻 error:", errorMessage) // Don't send error to state machine - we're retrying, not giving up @@ -181,7 +172,7 @@ const config = ( return ctx } -const startAgent = (service: Service, ctx: PhoneContext) => { +const startAgent = async (service: Service, ctx: PhoneContext, hasDialFailure = false) => { let streamPlayback = player.playStream() const agent = new Agent({ @@ -193,7 +184,10 @@ const startAgent = (service: Service, ctx: PhoneContext) => }) handleAgentEvents(service, agent, streamPlayback) - const stopListening = startListening(service, agent) + + const stopListening = hasDialFailure + ? await startListeningAfterDialFailure(agent, ctx.dialFailureReason) + : startListening(service, agent) ctx.stopAgent = () => { stopListening() @@ -204,7 +198,7 @@ const startAgent = (service: Service, ctx: PhoneContext) => return ctx } -const startListening = (service: Service, agent: Agent) => { +function startListening(service: Service, agent: Agent) { const abortAgent = new AbortController() new Promise(async (resolve) => { @@ -259,6 +253,42 @@ const startListening = (service: Service, agent: Agent) => return () => abortAgent.abort() } +async function startListeningAfterDialFailure(agent: Agent, dialFailureReason?: string) { + const abortAgent = new AbortController() + const recorder = await Buzz.recorder() + const listenPlayback = recorder.start() + + const message = getFriendlyErrorMessage(dialFailureReason) + agent.events.on((event) => { + if (event.type === "connected") agent.sendMessage(`[SYSTEM: ${message}]`) + if (event.type === "disconnected") abortAgent.abort() + }) + + const backgroundNoisePlayback = await player.play(getSound("background"), { repeat: true }) + await agent.start() + + streamAudioToAgent(agent, listenPlayback, backgroundNoisePlayback, abortAgent.signal) + + return () => abortAgent.abort() +} + +async function streamAudioToAgent( + agent: Agent, + listenPlayback: Buzz.StreamingRecording, + backgroundNoisePlayback: Buzz.Playback | undefined, + signal: AbortSignal, +) { + for await (const chunk of listenPlayback.stream()) { + if (signal.aborted) { + agent.stop() + listenPlayback.stop() + backgroundNoisePlayback?.stop() + break + } + agent.sendAudio(chunk) + } +} + const handleAgentEvents = ( service: Service, agent: Agent, @@ -347,14 +377,22 @@ const answerCall = (ctx: PhoneContext) => { ctx.baresip.accept() } -const playDialFailedMessage = async () => { - log.error("📞 Call failed - playing error tone") - // Play fast busy signal (reorder tone) - const errorTone = await player.playTone([480, 620], 500) - await sleep(500) - errorTone.stop() - await player.playTone([480, 620], 500) - await sleep(500) +const storeDialFailure = (ctx: PhoneContext, event: { reason?: string }) => { + ctx.dialFailureReason = event.reason + return ctx +} + +const clearDialFailure = (ctx: PhoneContext) => { + ctx.dialFailureReason = undefined + return ctx +} + +function getFriendlyErrorMessage(rawReason?: string): string { + if (rawReason?.includes("Not registered")) { + return "The user's call failed. To fix it, they should contact Corey IRL." + } + + return "The user's call failed. Twilio isn't working. To fix it, they should contact Corey IRL." } const makeCall = async (ctx: PhoneContext) => { @@ -392,10 +430,13 @@ const stopRinger = (ctx: PhoneContext) => { } async function startDialToneAndAgent(this: any, ctx: PhoneContext) { - ctx = await startAgent(this, ctx) + const hasDialFailure = !!ctx.dialFailureReason + ctx = await startAgent(this, ctx, hasDialFailure) - await dialTonePlayback?.stop() - dialTonePlayback = await player.playTone([350, 440], Infinity) + if (!hasDialFailure) { + await dialTonePlayback?.stop() + dialTonePlayback = await player.playTone([350, 440], Infinity) + } return ctx } @@ -460,19 +501,19 @@ const phoneMachine = createMachine( t("remote-hang-up", "ready"), t("hang-up", "idle", a(hangUp))), ready: invoke(startDialToneAndAgent, - t("dial-start", "dialing", a(stopDialTone), r(dialStart), a(stopAgent)), - t("hang-up", "idle", a(stopDialTone), a(stopAgent)), - t("start-agent", "connectToAgent", a(stopDialTone))), + t("dial-start", "dialing", a(stopDialTone), r(dialStart), r(clearDialFailure), a(stopAgent)), + t("hang-up", "idle", a(stopDialTone), r(clearDialFailure), a(stopAgent)), + t("start-agent", "connectToAgent", a(stopDialTone), r(clearDialFailure))), connectToAgent: state( - t("hang-up", "idle", r(stopAgent)), - t("remote-hang-up", "ready", r(stopAgent))), + t("hang-up", "idle", r(stopAgent), r(clearDialFailure)), + t("remote-hang-up", "ready", r(stopAgent), r(clearDialFailure))), dialing: state( t("dial-stop", "outgoing"), t("digit_increment", "dialing", r(digitIncrement)), t("hang-up", "idle")), outgoing: invoke(makeCall, t("answered", "connected"), - t("dial-failed", "ready", a(playDialFailedMessage)), + t("dial-failed", "ready", r(storeDialFailure)), t("hang-up", "idle", a(hangUp))), aborted: state( t("hang-up", "idle")), diff --git a/src/sip.ts b/src/sip.ts index 267118c..069c534 100644 --- a/src/sip.ts +++ b/src/sip.ts @@ -5,6 +5,7 @@ import { processStdout, processStderr } from "./utils/stdio.ts" export class Baresip { baresipArgs: string[] process?: Bun.PipedSubprocess + registered = false callEstablished = new Emitter<{ contact: string }>() callReceived = new Emitter<{ contact: string }>() hungUp = new Emitter() @@ -41,9 +42,13 @@ export class Baresip { } async dial(phoneNumber: string) { + if (!this.registered) { + this.dialFailed.emit({ reason: "Not registered with SIP server" }) + return + } const success = await executeCommand(`d${phoneNumber}`) if (!success) { - this.dialFailed.emit({ reason: "Command timed out - registration may have failed" }) + this.dialFailed.emit({ reason: "Command timed out" }) } } @@ -68,6 +73,7 @@ export class Baresip { } async restart() { + this.registered = false if (this.process) { this.process.kill() this.process = undefined @@ -102,6 +108,7 @@ export class Baresip { const registrationSuccessMatch = line.match(/\[\d+ bindings?\]/) if (registrationSuccessMatch) { + this.registered = true this.registrationSuccess.emit() } @@ -110,9 +117,11 @@ export class Baresip { if (registrationFailedMatch) { const [, statusCode, reason] = registrationFailedMatch log.error(`Registration failed: ${statusCode} ${reason}`) + this.registered = false this.error.emit({ message: line, statusCode, reason }) } else if (socketInUseMatch) { log.error(`Registration failed: socket in use`) + this.registered = false this.error.emit({ message: line }) } } diff --git a/src/utils/twilio.ts b/src/utils/twilio.ts deleted file mode 100644 index ea95066..0000000 --- a/src/utils/twilio.ts +++ /dev/null @@ -1,50 +0,0 @@ -const accountSid = process.env.TWILIO_ACCOUNT_SID -const authToken = process.env.TWILIO_AUTH_TOKEN - -type AccountStatus = "active" | "suspended" | "closed" - -interface TwilioAccountInfo { - status: AccountStatus - balance?: string - currency?: string -} - -export async function getTwilioAccountInfo(): Promise { - if (!accountSid || !authToken) { - return undefined - } - - const credentials = Buffer.from(`${accountSid}:${authToken}`).toString("base64") - const headers = { Authorization: `Basic ${credentials}` } - - const [accountRes, balanceRes] = await Promise.all([ - fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}.json`, { headers }), - fetch(`https://api.twilio.com/2010-04-01/Accounts/${accountSid}/Balance.json`, { headers }), - ]) - - if (!accountRes.ok) { - return undefined - } - - const account = (await accountRes.json()) as { status: AccountStatus } - const info: TwilioAccountInfo = { status: account.status } - - if (balanceRes.ok) { - const balance = (await balanceRes.json()) as { balance: string; currency: string } - info.balance = balance.balance - info.currency = balance.currency - } - - return info -} - -export function formatTwilioError(info: TwilioAccountInfo): string { - if (info.status === "suspended") { - const balanceInfo = info.balance ? ` (balance: ${info.balance} ${info.currency})` : "" - return `Twilio account suspended${balanceInfo} - add funds at twilio.com/console` - } - if (info.status === "closed") { - return "Twilio account is closed" - } - return `Twilio account status: ${info.status}` -} diff --git a/src/vesta/README.md b/src/vesta/README.md new file mode 100644 index 0000000..4e596f3 --- /dev/null +++ b/src/vesta/README.md @@ -0,0 +1,55 @@ +# vesta + +CLI tool for sending messages to a Vestaboard. + +## Setup + +```bash +bun install +``` + +Add your API key to `.env`: +``` +VESTABOARD_API_KEY=your_key_here +``` + +## Usage + +```bash +bun src/cli.ts [args...] +``` + +Example: +```bash +bun src/cli.ts words hello world foo +``` + +## Plugin Ideas + +### Physical Interaction +- **SMS gateway** - Text your board from anywhere, let guests text it at parties +- **NFC tags** - Tap spots around your house to trigger different displays +- **Pi buttons** - Physical "mood buttons" - hit one for motivation, one for jokes, one for chaos +- **QR code guest book** - Visitors scan and leave a message + +### Generative/Visual +- **Game of Life** - Cellular automata with color tiles, evolving patterns +- **Matrix rain** - Characters cascading down with color trails +- **Waveform** - Audio input turns into color visualizations +- **Sunrise simulator** - Color gradient that shifts throughout the day + +### Data as Art +- **GitHub-style heatmap** - Your daily activity as a color grid +- **Air quality gradient** - Pulls AQI, renders as color mood +- **Heart rate from Apple Watch** - Live biometric ambient display +- **Network pulse** - Flickers when devices connect/disconnect + +### Games via SMS +- **Wordle** - Text guesses, board shows your progress +- **Hangman** - Play with friends remotely +- **Simon** - Color memory game with physical buttons + +### Home Awareness +- **Who's home** - Each family member gets a row, lights up based on presence +- **Chore roulette** - Spin and assign tasks with flair +- **Package tracker** - Delivery countdown with dramatic reveal diff --git a/src/vesta/cli.ts b/src/vesta/cli.ts new file mode 100644 index 0000000..0fbc059 --- /dev/null +++ b/src/vesta/cli.ts @@ -0,0 +1,107 @@ +#!/usr/bin/env bun +import { sendGrid } from "./vestaboard" + +const ROWS = 6 +const COLS = 22 + +const charToCode = (c: string): number => { + if (c === " ") return 0 + if (c >= "A" && c <= "Z") return c.charCodeAt(0) - 64 + if (c >= "a" && c <= "z") return c.charCodeAt(0) - 96 + if (c >= "0" && c <= "9") return c.charCodeAt(0) - 48 + 27 + const special: Record = { + "!": 37, + "@": 38, + "#": 39, + $: 40, + "(": 41, + ")": 42, + "-": 44, + "+": 46, + "&": 47, + "=": 48, + ";": 49, + ":": 50, + "'": 52, + '"': 53, + "%": 54, + ",": 55, + ".": 56, + "/": 59, + "?": 60, + "°": 62, + "🟥": 63, + "🟧": 64, + "🟨": 65, + "🟩": 66, + "🟦": 67, + "🟪": 68, + "⬜": 69, + "⬛": 70, + } + return special[c] ?? 0 +} + +const blankGrid = (): number[][] => Array.from({ length: ROWS }, () => Array(COLS).fill(0)) + +const textToGrid = (text: string): number[][] => { + const grid = blankGrid() + const lines = text.split("\n").slice(0, ROWS) + + lines.forEach((line, row) => { + const chars = [...line].slice(0, COLS) + const startCol = Math.floor((COLS - chars.length) / 2) + chars.forEach((char, i) => { + grid[row]![startCol + i] = charToCode(char) + }) + }) + + // Vertically center if fewer lines than rows + if (lines.length < ROWS) { + const offset = Math.floor((ROWS - lines.length) / 2) + const centered = blankGrid() + lines.forEach((_, i) => { + centered[i + offset] = grid[i] as any + }) + return centered + } + + return grid +} + +const usage = ` +Usage: bun src/vesta/cli.ts + +Examples: + bun src/vesta/cli.ts "Hello World" + bun src/vesta/cli.ts "Line 1" "Line 2" "Line 3" + echo "Piped text" | bun src/vesta/cli.ts + +Special characters: ! @ # $ ( ) - + & = ; : ' " % , . / ? +Colors: 🟥 🟧 🟨 🟩 🟦 🟪 ⬜ ⬛ +` + +const main = async () => { + let text: string + + if (process.argv.length > 2) { + text = process.argv.slice(2).join("\n") + } else if (!process.stdin.isTTY) { + text = await Bun.stdin.text() + } else { + console.log(usage) + process.exit(1) + } + + text = text.trim() + if (!text) { + console.error("Error: Empty message") + process.exit(1) + } + + console.log(`Sending: "${text.replace(/\n/g, " | ")}"`) + const grid = textToGrid(text) + await sendGrid(grid) +} + +main() diff --git a/src/vesta/draw.test.ts b/src/vesta/draw.test.ts new file mode 100644 index 0000000..f93f4f0 --- /dev/null +++ b/src/vesta/draw.test.ts @@ -0,0 +1,258 @@ +import { test, expect } from "bun:test" +import { draw, type DrawCommand } from "./draw" +import { renderGridToPng } from "./render" +import { mkdirSync, existsSync } from "fs" + +const OUTPUT_DIR = `${import.meta.dir}/test-output` + +if (!existsSync(OUTPUT_DIR)) { + mkdirSync(OUTPUT_DIR, { recursive: true }) +} + +const saveTestPng = async (name: string, commands: DrawCommand[]) => { + const grid = draw(commands) + const png = await renderGridToPng(grid) + await Bun.write(`${OUTPUT_DIR}/${name}.png`, png) + return grid +} + +const ROWS = 6 +const COLS = 22 + +// Helper to safely access grid cells (we know grid is always 6x22) +const at = (grid: number[][], r: number, c: number) => grid[r]![c]! +const rowAt = (grid: number[][], r: number) => grid[r]! + +test("fill - solid color", async () => { + const grid = await saveTestPng("fill-solid-blue", [{ cmd: "fill", color: "blue" }]) + expect(grid.length).toBe(ROWS) + expect(rowAt(grid, 0).length).toBe(COLS) + expect(grid.every((r) => r.every((c) => c === 67))).toBe(true) +}) + +test("rect - simple rectangle", async () => { + const grid = await saveTestPng("rect-simple", [ + { cmd: "fill", color: "black" }, + { cmd: "rect", x: 2, y: 1, w: 5, h: 3, color: "red" }, + ]) + expect(at(grid, 1, 2)).toBe(63) // red + expect(at(grid, 2, 4)).toBe(63) // red + expect(at(grid, 0, 0)).toBe(70) // black +}) + +test("rect - full width row", async () => { + const grid = await saveTestPng("rect-full-row", [ + { cmd: "fill", color: "white" }, + { cmd: "rect", x: 0, y: 0, w: 22, h: 1, color: "orange" }, + { cmd: "rect", x: 0, y: 5, w: 22, h: 1, color: "orange" }, + ]) + expect(rowAt(grid, 0).every((c) => c === 64)).toBe(true) + expect(rowAt(grid, 5).every((c) => c === 64)).toBe(true) + expect(rowAt(grid, 2).every((c) => c === 69)).toBe(true) +}) + +test("text - centered (default)", async () => { + const grid = await saveTestPng("text-centered", [ + { cmd: "fill", color: "black" }, + { cmd: "text", content: "HELLO", row: 2 }, + ]) + // "HELLO" is 5 chars, centered in 22 = starts at position 8 + expect(at(grid, 2, 8)).toBe(8) // H + expect(at(grid, 2, 9)).toBe(5) // E + expect(at(grid, 2, 10)).toBe(12) // L + expect(at(grid, 2, 11)).toBe(12) // L + expect(at(grid, 2, 12)).toBe(15) // O +}) + +test("text - left aligned", async () => { + const grid = await saveTestPng("text-left", [ + { cmd: "fill", color: "black" }, + { cmd: "text", content: "HI", row: 0, align: "left" }, + ]) + expect(at(grid, 0, 0)).toBe(8) // H + expect(at(grid, 0, 1)).toBe(9) // I +}) + +test("text - right aligned", async () => { + const grid = await saveTestPng("text-right", [ + { cmd: "fill", color: "black" }, + { cmd: "text", content: "HI", row: 0, align: "right" }, + ]) + expect(at(grid, 0, 20)).toBe(8) // H + expect(at(grid, 0, 21)).toBe(9) // I +}) + +test("text - with startCol/endCol bounds", async () => { + const grid = await saveTestPng("text-bounded", [ + { cmd: "fill", color: "black" }, + { cmd: "text", content: "HI", row: 2, startCol: 5, endCol: 16, align: "center" }, + ]) + // Width is 12 (5-16 inclusive), "HI" is 2 chars, centered = starts at col 10 + expect(at(grid, 2, 10)).toBe(8) // H + expect(at(grid, 2, 11)).toBe(9) // I +}) + +test("text - left aligned with bounds", async () => { + const grid = await saveTestPng("text-bounded-left", [ + { cmd: "fill", color: "black" }, + { cmd: "text", content: "HI", row: 2, startCol: 4, endCol: 17, align: "left" }, + ]) + expect(at(grid, 2, 4)).toBe(8) // H + expect(at(grid, 2, 5)).toBe(9) // I +}) + +test("text - right aligned with bounds", async () => { + const grid = await saveTestPng("text-bounded-right", [ + { cmd: "fill", color: "black" }, + { cmd: "text", content: "HI", row: 2, startCol: 4, endCol: 17, align: "right" }, + ]) + expect(at(grid, 2, 16)).toBe(8) // H + expect(at(grid, 2, 17)).toBe(9) // I +}) + +test("text_block - word wrap", async () => { + const grid = await saveTestPng("text-block-wrap", [ + { cmd: "fill", color: "black" }, + { cmd: "text_block", content: "HAPPY BIRTHDAY BEATRICE", startRow: 1 }, + ]) + expect(grid.length).toBe(ROWS) +}) + +test("text_block - with column bounds", async () => { + const grid = await saveTestPng("text-block-bounded", [ + { cmd: "fill", color: "black" }, + { cmd: "text_block", content: "THE QUICK BROWN FOX JUMPS", startRow: 0, startCol: 2, endCol: 19 }, + ]) + // First and last 2 cols should remain black + expect(at(grid, 0, 0)).toBe(70) + expect(at(grid, 0, 1)).toBe(70) + expect(at(grid, 0, 20)).toBe(70) + expect(at(grid, 0, 21)).toBe(70) +}) + +test("text_block - with row bounds", async () => { + const grid = await saveTestPng("text-block-row-bounded", [ + { cmd: "fill", color: "black" }, + { cmd: "text_block", content: "A B C D E F G H I J K L M", startRow: 2, endRow: 4 }, + ]) + // Should only use rows 2-4 + expect(at(grid, 0, 0)).toBe(70) // black, untouched + expect(at(grid, 1, 0)).toBe(70) // black, untouched + expect(at(grid, 5, 0)).toBe(70) // black, untouched +}) + +test("text_block - overflow ellipsis", async () => { + const grid = await saveTestPng("text-block-overflow", [ + { cmd: "fill", color: "black" }, + { cmd: "text_block", content: "THIS IS A VERY LONG MESSAGE THAT WILL NOT FIT", startRow: 2, endRow: 3, overflow: "ellipsis" }, + ]) + expect(grid.length).toBe(ROWS) +}) + +test("border - all sides", async () => { + const grid = await saveTestPng("border-all", [ + { cmd: "fill", color: "black" }, + { cmd: "border", color: "blue" }, + ]) + expect(rowAt(grid, 0).every((c) => c === 67)).toBe(true) + expect(rowAt(grid, 5).every((c) => c === 67)).toBe(true) + expect(at(grid, 2, 0)).toBe(67) + expect(at(grid, 2, 21)).toBe(67) + expect(at(grid, 2, 10)).toBe(70) +}) + +test("border - top and bottom only", async () => { + const grid = await saveTestPng("border-top-bottom", [ + { cmd: "fill", color: "black" }, + { cmd: "border", color: "orange", sides: ["top", "bottom"] }, + ]) + expect(rowAt(grid, 0).every((c) => c === 64)).toBe(true) + expect(rowAt(grid, 5).every((c) => c === 64)).toBe(true) + expect(at(grid, 2, 0)).toBe(70) + expect(at(grid, 2, 21)).toBe(70) +}) + +test("gradient - horizontal", async () => { + const grid = await saveTestPng("gradient-horizontal", [ + { cmd: "gradient", direction: "horizontal", colors: ["purple", "blue", "green", "yellow", "orange", "red"] }, + ]) + expect(at(grid, 0, 0)).toBe(68) // purple + expect(at(grid, 0, 21)).toBe(63) // red +}) + +test("gradient - vertical", async () => { + const grid = await saveTestPng("gradient-vertical", [ + { cmd: "gradient", direction: "vertical", colors: ["blue", "green", "yellow"] }, + ]) + expect(at(grid, 0, 10)).toBe(67) // blue + expect(at(grid, 5, 10)).toBe(65) // yellow +}) + +test("circle - small", async () => { + const grid = await saveTestPng("circle-small", [ + { cmd: "fill", color: "white" }, + { cmd: "circle", cx: 11, cy: 3, r: 2, color: "red" }, + ]) + expect(at(grid, 3, 11)).toBe(63) // red center +}) + +test("line - horizontal", async () => { + const grid = await saveTestPng("line-horizontal", [ + { cmd: "fill", color: "black" }, + { cmd: "line", x1: 2, y1: 3, x2: 19, y2: 3, color: "yellow" }, + ]) + expect(at(grid, 3, 2)).toBe(65) + expect(at(grid, 3, 10)).toBe(65) + expect(at(grid, 3, 19)).toBe(65) +}) + +test("line - diagonal", async () => { + const grid = await saveTestPng("line-diagonal", [ + { cmd: "fill", color: "black" }, + { cmd: "line", x1: 0, y1: 0, x2: 21, y2: 5, color: "green" }, + ]) + expect(at(grid, 0, 0)).toBe(66) + expect(at(grid, 5, 21)).toBe(66) +}) + +test("combined - birthday message", async () => { + await saveTestPng("combined-birthday", [ + { cmd: "fill", color: "black" }, + { cmd: "border", color: "orange", sides: ["top", "bottom"] }, + { cmd: "text_block", content: "HAPPY BIRTHDAY BEATRICE", startRow: 2, endRow: 3 }, + ]) +}) + +test("combined - quote with accent", async () => { + await saveTestPng("combined-quote", [ + { cmd: "fill", color: "black" }, + { cmd: "text_block", content: "KINDLE A LIGHT OF MEANING IN THE DARKNESS", startRow: 0, endRow: 3, startCol: 2, endCol: 19 }, + { cmd: "text", content: "-CARL JUNG", row: 5 }, + ]) +}) + +test("combined - alert with border", async () => { + await saveTestPng("combined-alert", [ + { cmd: "fill", color: "black" }, + { cmd: "border", color: "red" }, + { cmd: "text", content: "ALERT", row: 2 }, + { cmd: "text", content: "MEETING AT 6PM", row: 3 }, + ]) +}) + +test("combined - asymmetric layout", async () => { + await saveTestPng("combined-asymmetric", [ + { cmd: "fill", color: "black" }, + { cmd: "rect", x: 0, y: 0, w: 4, h: 6, color: "blue" }, + { cmd: "text_block", content: "QUOTE GOES HERE ON THE RIGHT SIDE", startCol: 5, endCol: 21, startRow: 1, endRow: 4, align: "left" }, + ]) +}) + +test("layered - shapes overlap correctly", async () => { + await saveTestPng("layered-shapes", [ + { cmd: "fill", color: "white" }, + { cmd: "rect", x: 0, y: 0, w: 22, h: 3, color: "blue" }, + { cmd: "rect", x: 5, y: 1, w: 12, h: 4, color: "yellow" }, + { cmd: "circle", cx: 11, cy: 3, r: 2, color: "red" }, + ]) +}) diff --git a/src/vesta/draw.ts b/src/vesta/draw.ts new file mode 100644 index 0000000..dc2d90f --- /dev/null +++ b/src/vesta/draw.ts @@ -0,0 +1,366 @@ +const ROWS = 6 +const COLS = 22 + +type Color = "red" | "orange" | "yellow" | "green" | "blue" | "purple" | "white" | "black" +type Align = "left" | "center" | "right" +type Side = "top" | "bottom" | "left" | "right" +type Overflow = "ellipsis" | "truncate" | "squeeze" | "error" + +const colorToCode: Record = { + red: 63, + orange: 64, + yellow: 65, + green: 66, + blue: 67, + purple: 68, + white: 69, + black: 70, +} + +const charToCode: Record = { + " ": 0, + A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, I: 9, + J: 10, K: 11, L: 12, M: 13, N: 14, O: 15, P: 16, Q: 17, + R: 18, S: 19, T: 20, U: 21, V: 22, W: 23, X: 24, Y: 25, Z: 26, + "1": 27, "2": 28, "3": 29, "4": 30, "5": 31, "6": 32, "7": 33, "8": 34, "9": 35, "0": 36, + "!": 37, "@": 38, "#": 39, "$": 40, "(": 41, ")": 42, "-": 44, "+": 46, + "&": 47, "=": 48, ";": 49, ":": 50, "'": 52, '"': 53, "%": 54, ",": 55, + ".": 56, "/": 59, "?": 60, "°": 62, +} + +interface FillCmd { + cmd: "fill" + color: Color +} + +interface RectCmd { + cmd: "rect" + x: number + y: number + w: number + h: number + color: Color +} + +interface CircleCmd { + cmd: "circle" + cx: number + cy: number + r: number + color: Color +} + +interface LineCmd { + cmd: "line" + x1: number + y1: number + x2: number + y2: number + color: Color +} + +interface TextCmd { + cmd: "text" + content: string + row: number + align?: Align + startCol?: number + endCol?: number +} + +interface TextBlockCmd { + cmd: "text_block" + content: string + startRow?: number + endRow?: number + startCol?: number + endCol?: number + align?: Align + overflow?: Overflow +} + +interface BorderCmd { + cmd: "border" + color: Color + sides?: Side[] +} + +interface GradientCmd { + cmd: "gradient" + direction: "horizontal" | "vertical" + colors: Color[] +} + +export type DrawCommand = + | FillCmd + | RectCmd + | CircleCmd + | LineCmd + | TextCmd + | TextBlockCmd + | BorderCmd + | GradientCmd + +const createGrid = (): number[][] => { + return Array.from({ length: ROWS }, () => Array(COLS).fill(0)) +} + +const setPixel = (grid: number[][], x: number, y: number, code: number) => { + if (x >= 0 && x < COLS && y >= 0 && y < ROWS) { + grid[y]![x] = code + } +} + +const fill = (grid: number[][], color: Color) => { + const code = colorToCode[color] + for (let y = 0; y < ROWS; y++) { + for (let x = 0; x < COLS; x++) { + grid[y]![x] = code + } + } +} + +const rect = (grid: number[][], x: number, y: number, w: number, h: number, color: Color) => { + const code = colorToCode[color] + for (let dy = 0; dy < h; dy++) { + for (let dx = 0; dx < w; dx++) { + setPixel(grid, x + dx, y + dy, code) + } + } +} + +const circle = (grid: number[][], cx: number, cy: number, r: number, color: Color) => { + const code = colorToCode[color] + for (let y = 0; y < ROWS; y++) { + for (let x = 0; x < COLS; x++) { + const dx = x - cx + const dy = y - cy + if (dx * dx + dy * dy <= r * r) { + grid[y]![x] = code + } + } + } +} + +const line = (grid: number[][], x1: number, y1: number, x2: number, y2: number, color: Color) => { + const code = colorToCode[color] + const dx = Math.abs(x2 - x1) + const dy = Math.abs(y2 - y1) + const sx = x1 < x2 ? 1 : -1 + const sy = y1 < y2 ? 1 : -1 + let err = dx - dy + + let x = x1 + let y = y1 + + while (true) { + setPixel(grid, x, y, code) + if (x === x2 && y === y2) break + const e2 = 2 * err + if (e2 > -dy) { + err -= dy + x += sx + } + if (e2 < dx) { + err += dx + y += sy + } + } +} + +const text = ( + grid: number[][], + content: string, + row: number, + startCol = 0, + endCol = COLS - 1, + align: Align = "center" +) => { + const availableWidth = endCol - startCol + 1 + const textLen = Math.min(content.length, availableWidth) + const truncatedContent = content.slice(0, textLen) + + let startX: number + if (align === "left") { + startX = startCol + } else if (align === "right") { + startX = endCol - textLen + 1 + } else { + // center + startX = startCol + Math.floor((availableWidth - textLen) / 2) + } + + for (let i = 0; i < truncatedContent.length; i++) { + const char = truncatedContent[i]!.toUpperCase() + const code = charToCode[char] ?? 0 + setPixel(grid, startX + i, row, code) + } +} + +const wrapText = (content: string, maxWidth: number): string[] => { + const words = content.split(" ") + const lines: string[] = [] + let currentLine = "" + + for (const word of words) { + if (word.length > maxWidth) { + if (currentLine) { + lines.push(currentLine.trim()) + currentLine = "" + } + let remaining = word + while (remaining.length > maxWidth) { + lines.push(remaining.slice(0, maxWidth - 1) + "-") + remaining = remaining.slice(maxWidth - 1) + } + currentLine = remaining + } else if ((currentLine + " " + word).trim().length <= maxWidth) { + currentLine = (currentLine + " " + word).trim() + } else { + if (currentLine) { + lines.push(currentLine) + } + currentLine = word + } + } + + if (currentLine) { + lines.push(currentLine) + } + + return lines +} + +const textBlock = ( + grid: number[][], + content: string, + startRow = 0, + endRow = ROWS - 1, + startCol = 0, + endCol = COLS - 1, + align: Align = "center", + overflow: Overflow = "ellipsis" +) => { + const availableWidth = endCol - startCol + 1 + const availableRows = endRow - startRow + 1 + let lines = wrapText(content, availableWidth) + + if (lines.length > availableRows) { + if (overflow === "ellipsis") { + lines = lines.slice(0, availableRows) + const lastLine = lines[lines.length - 1]! + if (lastLine.length > availableWidth - 3) { + lines[lines.length - 1] = lastLine.slice(0, availableWidth - 3) + "..." + } else { + lines[lines.length - 1] = lastLine + "..." + } + } else if (overflow === "truncate") { + lines = lines.slice(0, availableRows) + } else if (overflow === "squeeze") { + // Try expanding bounds by 1 on each side + const newStartCol = Math.max(0, startCol - 1) + const newEndCol = Math.min(COLS - 1, endCol + 1) + if (newStartCol < startCol || newEndCol > endCol) { + return textBlock(grid, content, startRow, endRow, newStartCol, newEndCol, align, overflow) + } + lines = lines.slice(0, availableRows) + } else if (overflow === "error") { + throw new Error(`Text overflow: ${lines.length} lines needed, only ${availableRows} available`) + } + } + + for (let i = 0; i < lines.length; i++) { + const row = startRow + i + if (row <= endRow) { + text(grid, lines[i]!, row, startCol, endCol, align) + } + } +} + +const border = (grid: number[][], color: Color, sides?: Side[]) => { + const code = colorToCode[color] + const allSides = sides ?? ["top", "bottom", "left", "right"] + + if (allSides.includes("top")) { + for (let x = 0; x < COLS; x++) { + grid[0]![x] = code + } + } + + if (allSides.includes("bottom")) { + for (let x = 0; x < COLS; x++) { + grid[ROWS - 1]![x] = code + } + } + + if (allSides.includes("left")) { + for (let y = 0; y < ROWS; y++) { + grid[y]![0] = code + } + } + + if (allSides.includes("right")) { + for (let y = 0; y < ROWS; y++) { + grid[y]![COLS - 1] = code + } + } +} + +const gradient = (grid: number[][], direction: "horizontal" | "vertical", colors: Color[]) => { + const codes = colors.map(c => colorToCode[c]) + + if (direction === "horizontal") { + for (let x = 0; x < COLS; x++) { + const t = x / (COLS - 1) + const idx = Math.min(Math.floor(t * codes.length), codes.length - 1) + const code = codes[idx]! + for (let y = 0; y < ROWS; y++) { + grid[y]![x] = code + } + } + } else { + for (let y = 0; y < ROWS; y++) { + const t = y / (ROWS - 1) + const idx = Math.min(Math.floor(t * codes.length), codes.length - 1) + const code = codes[idx]! + for (let x = 0; x < COLS; x++) { + grid[y]![x] = code + } + } + } +} + +export const draw = (commands: DrawCommand[]): number[][] => { + const grid = createGrid() + + for (const cmd of commands) { + switch (cmd.cmd) { + case "fill": + fill(grid, cmd.color) + break + case "rect": + rect(grid, cmd.x, cmd.y, cmd.w, cmd.h, cmd.color) + break + case "circle": + circle(grid, cmd.cx, cmd.cy, cmd.r, cmd.color) + break + case "line": + line(grid, cmd.x1, cmd.y1, cmd.x2, cmd.y2, cmd.color) + break + case "text": + text(grid, cmd.content, cmd.row, cmd.startCol, cmd.endCol, cmd.align) + break + case "text_block": + textBlock(grid, cmd.content, cmd.startRow, cmd.endRow, cmd.startCol, cmd.endCol, cmd.align, cmd.overflow) + break + case "border": + border(grid, cmd.color, cmd.sides) + break + case "gradient": + gradient(grid, cmd.direction, cmd.colors) + break + } + } + + return grid +} diff --git a/src/vesta/examples.json b/src/vesta/examples.json new file mode 100644 index 0000000..a8f486f --- /dev/null +++ b/src/vesta/examples.json @@ -0,0 +1,991 @@ +{ + "testPrompts": [ + "Happy birthday Beatrice! She is turning 11.", + "Put up a morning motivational quote", + "Game night at 7pm tonight!", + "Make some pretty wintery art", + "Soccer practice is cancelled today", + "Pizza night! Dinner at 6", + "Welcome home grandma!", + "5 days until vacation!", + "Don't forget your umbrella, it's going to rain", + "Good luck on your test today Emma!", + "Happy anniversary mom and dad!", + "Please remember to feed the dog", + "Merry Christmas!", + "Big game today - go wildcats!", + "The Johnsons are coming for dinner at 7", + "Congrats on the promotion!", + "Lights out at 9pm tonight", + "Taco Tuesday!", + "I love you, have a great day", + "Movie night - pick your favorite!" + ], + "goldExamples": [ + { + "input": "Give me a matt cooke motivational quote", + "output": [ + { + "cmd": "fill", + "color": "black" + }, + { + "cmd": "text", + "content": "THERE'S A FUTURE", + "row": 0, + "align": "left" + }, + { + "cmd": "text", + "content": "VERSION OF YOU TELLING", + "row": 1, + "align": "left" + }, + { + "cmd": "text", + "content": "THE STORY OF HOW IT", + "row": 2, + "align": "left" + }, + { + "cmd": "text", + "content": "ALL CAME TOGETHER.", + "row": 3, + "align": "left" + }, + { + "cmd": "text", + "content": "-MATT COOKE", + "row": 4, + "align": "left" + } + ] + }, + { + "input": "Create a tour countdown that shows 15 minutes left", + "output": [ + { + "cmd": "fill", + "color": "black" + }, + { + "cmd": "rect", + "x": 0, + "y": 0, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 1, + "y": 0, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 2, + "y": 0, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 3, + "y": 0, + "w": 1, + "h": 1, + "color": "green" + }, + { + "cmd": "rect", + "x": 4, + "y": 0, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 5, + "y": 0, + "w": 1, + "h": 1, + "color": "purple" + }, + { + "cmd": "rect", + "x": 6, + "y": 0, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 7, + "y": 0, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 8, + "y": 0, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 9, + "y": 0, + "w": 1, + "h": 1, + "color": "green" + }, + { + "cmd": "rect", + "x": 10, + "y": 0, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 11, + "y": 0, + "w": 1, + "h": 1, + "color": "purple" + }, + { + "cmd": "rect", + "x": 12, + "y": 0, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 13, + "y": 0, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 14, + "y": 0, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 15, + "y": 0, + "w": 1, + "h": 1, + "color": "green" + }, + { + "cmd": "rect", + "x": 16, + "y": 0, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 17, + "y": 0, + "w": 1, + "h": 1, + "color": "purple" + }, + { + "cmd": "rect", + "x": 18, + "y": 0, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 19, + "y": 0, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 20, + "y": 0, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 21, + "y": 0, + "w": 1, + "h": 1, + "color": "green" + }, + { + "cmd": "rect", + "x": 0, + "y": 5, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 1, + "y": 5, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 2, + "y": 5, + "w": 1, + "h": 1, + "color": "purple" + }, + { + "cmd": "rect", + "x": 3, + "y": 5, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 4, + "y": 5, + "w": 1, + "h": 1, + "color": "green" + }, + { + "cmd": "rect", + "x": 5, + "y": 5, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 6, + "y": 5, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 7, + "y": 5, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 8, + "y": 5, + "w": 1, + "h": 1, + "color": "purple" + }, + { + "cmd": "rect", + "x": 9, + "y": 5, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 10, + "y": 5, + "w": 1, + "h": 1, + "color": "green" + }, + { + "cmd": "rect", + "x": 11, + "y": 5, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 12, + "y": 5, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 13, + "y": 5, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 14, + "y": 5, + "w": 1, + "h": 1, + "color": "purple" + }, + { + "cmd": "rect", + "x": 15, + "y": 5, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 16, + "y": 5, + "w": 1, + "h": 1, + "color": "green" + }, + { + "cmd": "rect", + "x": 17, + "y": 5, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 18, + "y": 5, + "w": 1, + "h": 1, + "color": "orange" + }, + { + "cmd": "rect", + "x": 19, + "y": 5, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 20, + "y": 5, + "w": 1, + "h": 1, + "color": "purple" + }, + { + "cmd": "rect", + "x": 21, + "y": 5, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "text", + "content": "WELCOME! THE NEXT", + "row": 1, + "startCol": 2, + "endCol": 19, + "align": "left" + }, + { + "cmd": "text", + "content": "TOUR STARTS IN...", + "row": 2, + "startCol": 2, + "endCol": 19, + "align": "left" + }, + { + "cmd": "text", + "content": "15 MINUTES!", + "row": 4 + } + ] + }, + { + "input": "Say mazel tov to zev!", + "output": [ + { + "cmd": "fill", + "color": "black" + }, + { + "cmd": "rect", + "x": 0, + "y": 0, + "w": 22, + "h": 1, + "color": "white" + }, + { + "cmd": "rect", + "x": 0, + "y": 1, + "w": 22, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 0, + "y": 4, + "w": 22, + "h": 1, + "color": "white" + }, + { + "cmd": "rect", + "x": 0, + "y": 5, + "w": 22, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 0, + "y": 2, + "w": 3, + "h": 1, + "color": "white" + }, + { + "cmd": "rect", + "x": 19, + "y": 2, + "w": 3, + "h": 1, + "color": "white" + }, + { + "cmd": "rect", + "x": 0, + "y": 3, + "w": 3, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 19, + "y": 3, + "w": 3, + "h": 1, + "color": "blue" + }, + { + "cmd": "text", + "content": "MAZEL TOV!", + "row": 2 + }, + { + "cmd": "text", + "content": "ZEV", + "row": 3 + } + ] + }, + { + "input": "Put up a bedtime reminder", + "output": [ + { + "cmd": "fill", + "color": "black" + }, + { + "cmd": "text", + "content": "BEDTIME", + "row": 0, + "startCol": 1, + "endCol": 8, + "align": "left" + }, + { + "cmd": "rect", + "x": 15, + "y": 0, + "w": 4, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 17, + "y": 1, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 16, + "y": 2, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 15, + "y": 3, + "w": 4, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 9, + "y": 1, + "w": 4, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 11, + "y": 2, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 10, + "y": 3, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 9, + "y": 4, + "w": 4, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 3, + "y": 2, + "w": 4, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 5, + "y": 3, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 4, + "y": 4, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 3, + "y": 5, + "w": 4, + "h": 1, + "color": "blue" + } + ] + }, + { + "input": "Show a train schedule for the polar express", + "output": [ + { + "cmd": "fill", + "color": "black" + }, + { + "cmd": "rect", + "x": 0, + "y": 0, + "w": 2, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 20, + "y": 0, + "w": 2, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 0, + "y": 1, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 21, + "y": 1, + "w": 1, + "h": 1, + "color": "red" + }, + { + "cmd": "text", + "content": "POLAR EXPRESS", + "row": 0 + }, + { + "cmd": "text", + "content": "12:00 AM", + "row": 1 + }, + { + "cmd": "text", + "content": "NORTH", + "row": 2, + "startCol": 2, + "endCol": 10, + "align": "left" + }, + { + "cmd": "text", + "content": "SOUTH", + "row": 2, + "startCol": 13, + "endCol": 21, + "align": "left" + }, + { + "cmd": "rect", + "x": 0, + "y": 3, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 0, + "y": 4, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 0, + "y": 5, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 11, + "y": 3, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 11, + "y": 4, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 11, + "y": 5, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "text", + "content": "03 MINS", + "row": 3, + "startCol": 2, + "endCol": 10, + "align": "left" + }, + { + "cmd": "text", + "content": "07 MINS", + "row": 4, + "startCol": 2, + "endCol": 10, + "align": "left" + }, + { + "cmd": "text", + "content": "10 MINS", + "row": 5, + "startCol": 2, + "endCol": 10, + "align": "left" + }, + { + "cmd": "text", + "content": "02 MINS", + "row": 3, + "startCol": 13, + "endCol": 21, + "align": "left" + }, + { + "cmd": "text", + "content": "06 MINS", + "row": 4, + "startCol": 13, + "endCol": 21, + "align": "left" + }, + { + "cmd": "text", + "content": "09 MINS", + "row": 5, + "startCol": 13, + "endCol": 21, + "align": "left" + } + ] + }, + { + "input": "Create a stretch reminder for me", + "output": [ + { + "cmd": "fill", + "color": "black" + }, + { + "cmd": "text", + "content": "STAND UP", + "row": 2, + "startCol": 1, + "endCol": 10, + "align": "left" + }, + { + "cmd": "text", + "content": "& STRETCH", + "row": 3, + "startCol": 1, + "endCol": 10, + "align": "left" + }, + { + "cmd": "rect", + "x": 16, + "y": 1, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 13, + "y": 2, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 14, + "y": 2, + "w": 5, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 19, + "y": 2, + "w": 1, + "h": 1, + "color": "yellow" + }, + { + "cmd": "rect", + "x": 15, + "y": 3, + "w": 3, + "h": 1, + "color": "red" + }, + { + "cmd": "rect", + "x": 15, + "y": 4, + "w": 3, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 15, + "y": 5, + "w": 1, + "h": 1, + "color": "blue" + }, + { + "cmd": "rect", + "x": 17, + "y": 5, + "w": 1, + "h": 1, + "color": "blue" + } + ] + }, + { + "input": "birthday message for Sarah with confetti", + "output": [ + { "cmd": "fill", "color": "black" }, + { "cmd": "rect", "x": 0, "y": 0, "w": 1, "h": 1, "color": "red" }, + { "cmd": "rect", "x": 2, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 4, "y": 0, "w": 1, "h": 1, "color": "blue" }, + { "cmd": "rect", "x": 6, "y": 0, "w": 1, "h": 1, "color": "purple" }, + { "cmd": "rect", "x": 15, "y": 0, "w": 1, "h": 1, "color": "purple" }, + { "cmd": "rect", "x": 17, "y": 0, "w": 1, "h": 1, "color": "blue" }, + { "cmd": "rect", "x": 19, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 21, "y": 0, "w": 1, "h": 1, "color": "red" }, + { "cmd": "rect", "x": 1, "y": 1, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 5, "y": 1, "w": 1, "h": 1, "color": "green" }, + { "cmd": "rect", "x": 16, "y": 1, "w": 1, "h": 1, "color": "green" }, + { "cmd": "rect", "x": 20, "y": 1, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "text", "content": "HAPPY BIRTHDAY", "row": 2 }, + { "cmd": "text", "content": "SARAH!", "row": 3 }, + { "cmd": "rect", "x": 1, "y": 4, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 5, "y": 4, "w": 1, "h": 1, "color": "green" }, + { "cmd": "rect", "x": 16, "y": 4, "w": 1, "h": 1, "color": "green" }, + { "cmd": "rect", "x": 20, "y": 4, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 0, "y": 5, "w": 1, "h": 1, "color": "red" }, + { "cmd": "rect", "x": 2, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 4, "y": 5, "w": 1, "h": 1, "color": "blue" }, + { "cmd": "rect", "x": 6, "y": 5, "w": 1, "h": 1, "color": "purple" }, + { "cmd": "rect", "x": 15, "y": 5, "w": 1, "h": 1, "color": "purple" }, + { "cmd": "rect", "x": 17, "y": 5, "w": 1, "h": 1, "color": "blue" }, + { "cmd": "rect", "x": 19, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 21, "y": 5, "w": 1, "h": 1, "color": "red" } + ] + }, + { + "input": "Baby Emma was born on January 15th 2024 at 3:42pm, 7 pounds 4 ounces, 20 inches", + "output": [ + { "cmd": "fill", "color": "black" }, + { "cmd": "rect", "x": 0, "y": 1, "w": 22, "h": 1, "color": "red" }, + { "cmd": "text", "content": "BABY EMMA", "row": 0 }, + { "cmd": "text", "content": "DATE: JAN 15 2024", "row": 2, "startCol": 1, "endCol": 21, "align": "left" }, + { "cmd": "text", "content": "TIME: 3:42 PM", "row": 3, "startCol": 1, "endCol": 21, "align": "left" }, + { "cmd": "text", "content": "WEIGHT: 7 LBS 4 OZ", "row": 4, "startCol": 1, "endCol": 21, "align": "left" }, + { "cmd": "text", "content": "LENGTH: 20 IN", "row": 5, "startCol": 1, "endCol": 21, "align": "left" } + ] + }, + { + "input": "Congrats on the promotion!", + "output": [ + { "cmd": "fill", "color": "black" }, + { "cmd": "rect", "x": 0, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 1, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 2, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 3, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 4, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 5, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 6, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 7, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 8, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 9, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 10, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 11, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 12, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 13, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 14, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 15, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 16, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 17, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 18, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 19, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 20, "y": 0, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 21, "y": 0, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 0, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 1, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 2, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 3, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 4, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 5, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 6, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 7, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 8, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 9, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 10, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 11, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 12, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 13, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 14, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 15, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 16, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 17, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 18, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 19, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "rect", "x": 20, "y": 5, "w": 1, "h": 1, "color": "orange" }, + { "cmd": "rect", "x": 21, "y": 5, "w": 1, "h": 1, "color": "yellow" }, + { "cmd": "text", "content": "CONGRATS ON THE", "row": 2 }, + { "cmd": "text", "content": "PROMOTION!", "row": 3 } + ] + } + ] +} \ No newline at end of file diff --git a/src/vesta/generate.ts b/src/vesta/generate.ts new file mode 100644 index 0000000..71f2145 --- /dev/null +++ b/src/vesta/generate.ts @@ -0,0 +1,55 @@ +import { GoogleGenerativeAI } from "@google/generative-ai" +import { draw, type DrawCommand } from "./draw" +import { sendGrid } from "./vestaboard" + +const buildSystemPrompt = async (): Promise => { + const promptMd = await Bun.file(`${import.meta.dir}/prompt.md`).text() + const examplesJson = await Bun.file(`${import.meta.dir}/examples.json`).json() + + const examplesText = examplesJson.goldExamples + .map((ex: { input: string; output: unknown }) => `Input: "${ex.input}"\nOutput: ${JSON.stringify(ex.output)}`) + .join("\n\n") + + return `${promptMd} + +## Examples + +${examplesText}` +} + +export const generateVestaboard = async (query: string): Promise => { + const apiKey = process.env.GEMINI_API_KEY + if (!apiKey) { + throw new Error("GEMINI_API_KEY not set in environment") + } + + const genAI = new GoogleGenerativeAI(apiKey) + const model = genAI.getGenerativeModel({ + model: "gemini-2.5-flash", + generationConfig: { + temperature: 0.7, + responseMimeType: "application/json", + }, + }) + + const systemPrompt = await buildSystemPrompt() + + const result = await model.generateContent(`${systemPrompt}\n\n## User Request\n\n${query}`) + const text = result.response.text() + + let commands: DrawCommand[] + try { + commands = JSON.parse(text) + } catch { + throw new Error(`Failed to parse Gemini response as JSON: ${text}`) + } + + if (!Array.isArray(commands)) { + throw new Error(`Expected array of commands, got: ${typeof commands}`) + } + + const grid = draw(commands) + await sendGrid(grid) + + return "The vestaboard design has been generated and sent successfully." +} diff --git a/src/vesta/prompt.md b/src/vesta/prompt.md new file mode 100644 index 0000000..fb21f74 --- /dev/null +++ b/src/vesta/prompt.md @@ -0,0 +1,27 @@ +# Vestaboard Layout Generator + +Output a JSON array of draw commands for a 6-row × 22-column display. + +## Rules +1. First command MUST be `fill` +2. Text has black cell backgrounds - use black `rect` behind text on colored backgrounds +3. Colors: red, orange, yellow, green, blue, purple, white, black +4. Coordinates: rows 0-5, columns 0-21 + +## Commands + +``` +fill { "cmd": "fill", "color": "black" } +rect { "cmd": "rect", "x": 0, "y": 0, "w": 22, "h": 1, "color": "orange" } +text { "cmd": "text", "content": "HELLO", "row": 2, "color": "white" } +text_block { "cmd": "text_block", "content": "LONG MESSAGE", "startRow": 1, "endRow": 4, "color": "white" } +border { "cmd": "border", "color": "blue", "sides": ["top", "bottom"] } +gradient { "cmd": "gradient", "direction": "horizontal", "colors": ["blue", "green", "yellow"] } +circle { "cmd": "circle", "cx": 11, "cy": 3, "r": 2, "color": "red" } +line { "cmd": "line", "x1": 0, "y1": 0, "x2": 21, "y2": 5, "color": "green" } +``` + +text/text_block options: `align` (left/center/right), `startCol`, `endCol` + +## Output +JSON array only. No markdown, no explanation. diff --git a/src/vesta/render.ts b/src/vesta/render.ts new file mode 100644 index 0000000..160e74f --- /dev/null +++ b/src/vesta/render.ts @@ -0,0 +1,151 @@ +import sharp from "sharp" + +// Character to vestaboard code mapping +const charToCode: Record = { + " ": 0, + A: 1, B: 2, C: 3, D: 4, E: 5, F: 6, G: 7, H: 8, I: 9, + J: 10, K: 11, L: 12, M: 13, N: 14, O: 15, P: 16, Q: 17, + R: 18, S: 19, T: 20, U: 21, V: 22, W: 23, X: 24, Y: 25, Z: 26, + "1": 27, "2": 28, "3": 29, "4": 30, "5": 31, "6": 32, "7": 33, "8": 34, "9": 35, "0": 36, + "!": 37, "@": 38, "#": 39, "$": 40, "(": 41, ")": 42, "-": 44, "+": 46, + "&": 47, "=": 48, ";": 49, ":": 50, "'": 52, '"': 53, "%": 54, ",": 55, + ".": 56, "/": 59, "?": 60, "°": 62, + "🟥": 63, "🟧": 64, "🟨": 65, "🟩": 66, "🟦": 67, "🟪": 68, "⬜": 69, "⬛": 70, +} + +// Code to display info for rendering +const codeToDisplay: Record = { + 0: { bg: "#1a1a1a", fg: "#ffffff" }, // space (empty black tile) + 63: { bg: "#e63946", fg: "#ffffff" }, // red + 64: { bg: "#f4a261", fg: "#1a1a1a" }, // orange + 65: { bg: "#e9c46a", fg: "#1a1a1a" }, // yellow + 66: { bg: "#2a9d8f", fg: "#ffffff" }, // green + 67: { bg: "#0077b6", fg: "#ffffff" }, // blue + 68: { bg: "#9b5de5", fg: "#ffffff" }, // purple + 69: { bg: "#ffffff", fg: "#1a1a1a" }, // white + 70: { bg: "#1a1a1a", fg: "#ffffff" }, // black +} + +// Code to character (for letters/numbers/symbols) +const codeToChar: Record = {} +for (const [char, code] of Object.entries(charToCode)) { + if (code >= 1 && code <= 62) { + codeToChar[code] = char + } +} + +const TILE_SIZE = 24 +const GAP = 2 +const COLS = 22 +const ROWS = 6 + +// Parse emoji grid string to number array +export const parseEmojiGrid = (grid: string): number[][] => { + const lines = grid.trim().split("\n") + const result: number[][] = [] + + for (const line of lines) { + const row: number[] = [] + const chars = [...line] // Handle multi-byte emoji correctly + + for (const char of chars) { + const code = charToCode[char.toUpperCase()] + if (code !== undefined) { + row.push(code) + } + } + + // Pad or trim to 22 columns + while (row.length < COLS) row.push(0) + if (row.length > COLS) row.length = COLS + + result.push(row) + } + + // Pad or trim to 6 rows + while (result.length < ROWS) result.push(Array(COLS).fill(0)) + if (result.length > ROWS) result.length = ROWS + + return result +} + +// Convert number grid to emoji string +export const gridToEmoji = (grid: number[][]): string => { + const emojiMap: Record = { + 0: " ", + 63: "🟥", 64: "🟧", 65: "🟨", 66: "🟩", 67: "🟦", 68: "🟪", 69: "⬜", 70: "⬛", + } + + return grid + .map((row) => + row + .map((code) => { + if (emojiMap[code] !== undefined) return emojiMap[code] + if (codeToChar[code]) return codeToChar[code] + return " " + }) + .join("") + ) + .join("\n") +} + +// Render grid to SVG string +const gridToSvg = (grid: number[][]): string => { + const width = COLS * TILE_SIZE + (COLS - 1) * GAP + const height = ROWS * TILE_SIZE + (ROWS - 1) * GAP + + let svg = `` + svg += `` // background + + for (let row = 0; row < ROWS; row++) { + for (let col = 0; col < COLS; col++) { + const code = grid[row]![col]! + const x = col * (TILE_SIZE + GAP) + const y = row * (TILE_SIZE + GAP) + + // Determine tile appearance + let bg = "#1a1a1a" + let fg = "#ffffff" + let char: string | undefined + + const display = codeToDisplay[code] + if (display) { + bg = display.bg + fg = display.fg + } else if (codeToChar[code]) { + char = codeToChar[code] + bg = "#1a1a1a" + fg = "#ffffff" + } + + // Draw tile + svg += `` + + // Draw character if present + if (char) { + const fontSize = 14 + const textX = x + TILE_SIZE / 2 + const textY = y + TILE_SIZE / 2 + fontSize * 0.35 + // Escape XML special characters + const escaped = char.replace(/&/g, '&').replace(//g, '>') + svg += `${escaped}` + } + } + } + + svg += "" + return svg +} + +// Render emoji grid string to PNG buffer +export const renderToPng = async (emojiGrid: string): Promise => { + const grid = parseEmojiGrid(emojiGrid) + const svg = gridToSvg(grid) + return sharp(Buffer.from(svg)).png().toBuffer() +} + +// Render number grid to PNG buffer +export const renderGridToPng = async (grid: number[][]): Promise => { + const svg = gridToSvg(grid) + return sharp(Buffer.from(svg)).png().toBuffer() +} diff --git a/src/vesta/vestaboard.ts b/src/vesta/vestaboard.ts new file mode 100644 index 0000000..32d025c --- /dev/null +++ b/src/vesta/vestaboard.ts @@ -0,0 +1,26 @@ +const API_URL = "https://rw.vestaboard.com/" + +export const sendGrid = async (grid: number[][]): Promise => { + const apiKey = process.env.VESTABOARD_API_KEY + if (!apiKey) { + throw new Error("VESTABOARD_API_KEY not set in environment") + } + + console.log(`🌭 sending`) + const response = await fetch(API_URL, { + method: "POST", + headers: { + "X-Vestaboard-Read-Write-Key": apiKey, + "Content-Type": "application/json", + }, + body: JSON.stringify(grid), + }) + + if (!response.ok) { + const text = await response.text() + console.log(`🌭 Error ${text}`) + throw new Error(`Vestaboard API error (${response.status}): ${text}`) + } else { + console.log(`🌭 sent successfully`, await response.text()) + } +}