Merge pull request 'dial-failed-handling' (#7) from dial-failed-handling into main
Reviewed-on: #7
This commit is contained in:
commit
98c5d48846
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -34,3 +34,7 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|||
.DS_Store
|
||||
|
||||
.claude.worktrees/
|
||||
.worktrees/
|
||||
|
||||
docs/learning/
|
||||
docs/plans/
|
||||
BIN
sounds/stalling/hum.wav
Normal file
BIN
sounds/stalling/hum.wav
Normal file
Binary file not shown.
63
src/agent/personality.md
Normal file
63
src/agent/personality.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# Personality
|
||||
|
||||
You are a 1940s American switchboard operator who only answers questions. You have no ability to connect calls. You always sound like you're running a busy switchboard answering many questions. You are polite, efficient, and a little playful.
|
||||
|
||||
# Environment
|
||||
|
||||
The current time is {{current_datetime}}. The location of the caller is in San Francisco. Specifically, the rotary phone in the kitchen of 2145 Turk Blvd, San Francisco California.
|
||||
|
||||
You are working out of The Telephone Corporation headquarters in downtown San Francisco.
|
||||
|
||||
You are answering questions from a user that is talking to you on an old rotary phone. You don't know which person you are talking to. The residents names are:
|
||||
|
||||
- Corey: Dad born Feb 19th, 1980
|
||||
- Lisa: Mom born Dec 8th, 1979
|
||||
- Lulu: Girl born Oct 4th, 2011
|
||||
- Bee: Girl born Jan 15th, 2015
|
||||
- And Maggie the cat, but she doesn't talk.
|
||||
|
||||
# Capabilities
|
||||
|
||||
_Internet Search_
|
||||
You have access to modern information through your "special connection to the information exchange" (internet search). For example, when someone asks about:
|
||||
|
||||
- Current events, news, or recent happenings
|
||||
- Weather forecasts or current conditions
|
||||
- Sports scores or game schedules
|
||||
- Stock prices or market information
|
||||
- Business hours or store information
|
||||
- Facts you're uncertain about
|
||||
- Anything that requires up-to-date information
|
||||
|
||||
You should use your search capability to get accurate, current information. Think of it as consulting your "information operator network" - just like how you'd transfer calls, but for information instead.
|
||||
|
||||
_Vestaboard Messaging_
|
||||
You can send messages to a Vestaboard display. Just provide the message you want to send or a description of what you'd like displayed and the tool will handle the rest. The user may refer to the vestaboard as "the board" or "the message board."
|
||||
|
||||
_Ending the Call_
|
||||
When the user asks you to hang up, or wants to end the conversation you should call the “end_call” tool without saying goodbye or any other parting words.
|
||||
|
||||
# Tone
|
||||
|
||||
Speak in a fast, cheerful, slightly nasal cadence with short, snappy sentences. Avoid modern slang. Reference the technology of the era—switchboards, lines, operators, long-distance, "checking the wires," "consulting the information exchange"—when adding color. Keep responses under three sentences unless more detail is needed.
|
||||
|
||||
When you bring back o search for information, you might say things like:
|
||||
|
||||
- “Here is what I found.”
|
||||
- “Thanks for holding, I found this.”
|
||||
- “Sorry, That took awhile. Here is what I found“
|
||||
|
||||
# Goal
|
||||
|
||||
Your goal is to answer the user's question directly and efficiently, while maintaining your switchboard operator persona. Always consider whether a search would provide better, more current information than what you already know.
|
||||
|
||||
# Guardrails
|
||||
|
||||
- Do not connect real calls
|
||||
- Do not assume the person's gender, you don't know it!
|
||||
- Avoid modern slang
|
||||
- Stay in character as a 1940s switchboard operator
|
||||
- Do not end your sentences with a question
|
||||
- Use simple and brief statements
|
||||
- Actively use search capabilities when questions involve current information, recent events, or facts you're unsure about
|
||||
- Frame your searches in period-appropriate language
|
||||
103
src/phone.ts
103
src/phone.ts
|
|
@ -18,7 +18,6 @@ import GPIO from "./pins"
|
|||
import { Agent } from "./agent"
|
||||
import { searchWeb } from "./agent/tools"
|
||||
import { ring } from "./utils"
|
||||
import { getTwilioAccountInfo, formatTwilioError } from "./utils/twilio"
|
||||
import { getSound, WaitingSounds } from "./utils/waiting-sounds"
|
||||
|
||||
type CancelableTask = () => void
|
||||
|
|
@ -33,6 +32,7 @@ type PhoneContext = {
|
|||
ringer: GPIO.Output
|
||||
agentId: string
|
||||
agentKey: string
|
||||
dialFailureReason?: string
|
||||
}
|
||||
|
||||
type PhoneService = Service<typeof phoneMachine>
|
||||
|
|
@ -119,22 +119,18 @@ const startBaresip = async (phoneService: PhoneService, hook: GPIO.Input, ringer
|
|||
phoneService.send({ type: "remote-hang-up" })
|
||||
})
|
||||
|
||||
baresip.dialFailed.on(({ reason }) => {
|
||||
log.error("🐻 dial failed:", reason)
|
||||
phoneService.send({ type: "dial-failed", reason } as any)
|
||||
})
|
||||
|
||||
baresip.connect().catch((error) => {
|
||||
log.error("🐻 connection error:", error)
|
||||
phoneService.send({ type: "error", message: error.message })
|
||||
})
|
||||
|
||||
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
|
||||
|
|
@ -176,7 +172,7 @@ const config = (
|
|||
return ctx
|
||||
}
|
||||
|
||||
const startAgent = (service: Service<typeof phoneMachine>, ctx: PhoneContext) => {
|
||||
const startAgent = async (service: Service<typeof phoneMachine>, ctx: PhoneContext, hasDialFailure = false) => {
|
||||
let streamPlayback = player.playStream()
|
||||
|
||||
const agent = new Agent({
|
||||
|
|
@ -188,7 +184,10 @@ const startAgent = (service: Service<typeof phoneMachine>, 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()
|
||||
|
|
@ -199,7 +198,7 @@ const startAgent = (service: Service<typeof phoneMachine>, ctx: PhoneContext) =>
|
|||
return ctx
|
||||
}
|
||||
|
||||
const startListening = (service: Service<typeof phoneMachine>, agent: Agent) => {
|
||||
function startListening(service: Service<typeof phoneMachine>, agent: Agent) {
|
||||
const abortAgent = new AbortController()
|
||||
|
||||
new Promise<void>(async (resolve) => {
|
||||
|
|
@ -254,6 +253,42 @@ const startListening = (service: Service<typeof phoneMachine>, 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<typeof phoneMachine>,
|
||||
agent: Agent,
|
||||
|
|
@ -342,6 +377,24 @@ const answerCall = (ctx: PhoneContext) => {
|
|||
ctx.baresip.accept()
|
||||
}
|
||||
|
||||
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) => {
|
||||
log(`Dialing number: ${ctx.numberDialed}`)
|
||||
if (ctx.numberDialed === 1) {
|
||||
|
|
@ -377,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
|
||||
}
|
||||
|
|
@ -445,18 +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,
|
||||
outgoing: invoke(makeCall,
|
||||
t("answered", "connected"),
|
||||
t("dial-failed", "ready", r(storeDialFailure)),
|
||||
t("hang-up", "idle", a(hangUp))),
|
||||
aborted: state(
|
||||
t("hang-up", "idle")),
|
||||
|
|
|
|||
28
src/sip.ts
28
src/sip.ts
|
|
@ -5,9 +5,11 @@ 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()
|
||||
dialFailed = new Emitter<{ reason: string }>()
|
||||
error = new Emitter<{ message: string; statusCode?: string; reason?: string }>()
|
||||
registrationSuccess = new Emitter()
|
||||
|
||||
|
|
@ -39,8 +41,15 @@ export class Baresip {
|
|||
executeCommand("a")
|
||||
}
|
||||
|
||||
dial(phoneNumber: string) {
|
||||
executeCommand(`d${phoneNumber}`)
|
||||
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" })
|
||||
}
|
||||
}
|
||||
|
||||
hangUp() {
|
||||
|
|
@ -52,6 +61,7 @@ export class Baresip {
|
|||
this.callReceived.removeAllListeners()
|
||||
this.hungUp.removeAllListeners()
|
||||
this.registrationSuccess.removeAllListeners()
|
||||
this.dialFailed.removeAllListeners()
|
||||
this.error.removeAllListeners()
|
||||
}
|
||||
|
||||
|
|
@ -63,6 +73,7 @@ export class Baresip {
|
|||
}
|
||||
|
||||
async restart() {
|
||||
this.registered = false
|
||||
if (this.process) {
|
||||
this.process.kill()
|
||||
this.process = undefined
|
||||
|
|
@ -97,6 +108,7 @@ export class Baresip {
|
|||
|
||||
const registrationSuccessMatch = line.match(/\[\d+ bindings?\]/)
|
||||
if (registrationSuccessMatch) {
|
||||
this.registered = true
|
||||
this.registrationSuccess.emit()
|
||||
}
|
||||
|
||||
|
|
@ -105,23 +117,31 @@ 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const executeCommand = async (command: string) => {
|
||||
const executeCommand = async (command: string): Promise<boolean> => {
|
||||
try {
|
||||
const url = new URL(`/?${command}`, "http://127.0.0.1:8000")
|
||||
const response = await Bun.fetch(url)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 10000)
|
||||
|
||||
const response = await fetch(url, { signal: controller.signal })
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Error executing command: ${response.statusText}`)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
log.error("Failed to execute command:", error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<TwilioAccountInfo | undefined> {
|
||||
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}`
|
||||
}
|
||||
55
src/vesta/README.md
Normal file
55
src/vesta/README.md
Normal file
|
|
@ -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 <plugin> [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
|
||||
107
src/vesta/cli.ts
Normal file
107
src/vesta/cli.ts
Normal file
|
|
@ -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<string, number> = {
|
||||
"!": 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 <message>
|
||||
|
||||
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()
|
||||
258
src/vesta/draw.test.ts
Normal file
258
src/vesta/draw.test.ts
Normal file
|
|
@ -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" },
|
||||
])
|
||||
})
|
||||
366
src/vesta/draw.ts
Normal file
366
src/vesta/draw.ts
Normal file
|
|
@ -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<Color, number> = {
|
||||
red: 63,
|
||||
orange: 64,
|
||||
yellow: 65,
|
||||
green: 66,
|
||||
blue: 67,
|
||||
purple: 68,
|
||||
white: 69,
|
||||
black: 70,
|
||||
}
|
||||
|
||||
const charToCode: Record<string, number> = {
|
||||
" ": 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
|
||||
}
|
||||
991
src/vesta/examples.json
Normal file
991
src/vesta/examples.json
Normal file
|
|
@ -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 }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
55
src/vesta/generate.ts
Normal file
55
src/vesta/generate.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { GoogleGenerativeAI } from "@google/generative-ai"
|
||||
import { draw, type DrawCommand } from "./draw"
|
||||
import { sendGrid } from "./vestaboard"
|
||||
|
||||
const buildSystemPrompt = async (): Promise<string> => {
|
||||
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<string> => {
|
||||
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."
|
||||
}
|
||||
27
src/vesta/prompt.md
Normal file
27
src/vesta/prompt.md
Normal file
|
|
@ -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.
|
||||
151
src/vesta/render.ts
Normal file
151
src/vesta/render.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import sharp from "sharp"
|
||||
|
||||
// Character to vestaboard code mapping
|
||||
const charToCode: Record<string, number> = {
|
||||
" ": 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<number, { bg: string; fg: string; char?: string }> = {
|
||||
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<number, string> = {}
|
||||
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<number, string> = {
|
||||
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 xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
|
||||
svg += `<rect width="${width}" height="${height}" fill="#0d0d0d"/>` // 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 += `<rect x="${x}" y="${y}" width="${TILE_SIZE}" height="${TILE_SIZE}" rx="2" fill="${bg}"/>`
|
||||
|
||||
// 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, '<').replace(/>/g, '>')
|
||||
svg += `<text x="${textX}" y="${textY}" font-family="Arial, sans-serif" font-size="${fontSize}" font-weight="bold" fill="${fg}" text-anchor="middle">${escaped}</text>`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
svg += "</svg>"
|
||||
return svg
|
||||
}
|
||||
|
||||
// Render emoji grid string to PNG buffer
|
||||
export const renderToPng = async (emojiGrid: string): Promise<Buffer> => {
|
||||
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<Buffer> => {
|
||||
const svg = gridToSvg(grid)
|
||||
return sharp(Buffer.from(svg)).png().toBuffer()
|
||||
}
|
||||
26
src/vesta/vestaboard.ts
Normal file
26
src/vesta/vestaboard.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
const API_URL = "https://rw.vestaboard.com/"
|
||||
|
||||
export const sendGrid = async (grid: number[][]): Promise<void> => {
|
||||
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())
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user