moeru-ai/airi · error · Error

Screenshot must be PNG or JPEG.

Error message

Screenshot must be PNG or JPEG.

What it means

After reading the file, readImage() sniffs magic bytes: PNG (89 50 4E 47 0D 0A 1A 0A) or JPEG (FF D8 FF). Any other content is rejected because the returned data URL hardcodes image/png or image/jpeg and downstream consumers (model vision input) require those formats.

Solutions

  1. Let the AIRI CLI produce the screenshot (it emits PNG/JPEG) instead of placing your own file in the store
  2. Convert the image to PNG or JPEG before calling readImage
  3. Check the first bytes (PNG/JPEG magic) before calling to fail early

Example fix

// before
await copyFile('capture.webp', join(storeRoot, 'capture.png'))
await readImage({ path: join(storeRoot, 'capture.png') })
// after
const jpegPath = await sharp('capture.webp').jpeg().toFile(join(storeRoot, 'capture.jpg'))
await readImage({ path: jpegPath })
Defensive patterns

Strategy: validation

Validate before calling

import { readFile } from 'node:fs/promises'
async function isPngOrJpeg(p: string): Promise<boolean> {
  const b = (await readFile(p)).subarray(0, 3)
  const png = b[0] === 137 && b[1] === 80 && b[2] === 78
  const jpeg = b[0] === 255 && b[1] === 216 && b[2] === 255
  return png || jpeg
}

Type guard

function hasImageMagic(bytes: Uint8Array): boolean {
  const png = bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71
  const jpeg = bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255
  return png || jpeg
}

Try / catch

try {
  return await readImage({ path })
} catch (e) {
  if (e instanceof Error && e.message === 'Screenshot must be PNG or JPEG.') {
    const converted = await convertToPng(path)
    return readImage({ path: converted })
  }
  throw e
}

Prevention

When it happens

Trigger: The file inside the store is a WebP/BMP/GIF, an empty file, a partially written download, or a text/error file instead of a real screenshot.

Common situations: Another tool wrote into the store directory; a truncated capture from a crashed process; renaming a .webp to .png and expecting readImage to accept it.

Understand the failure class

Background: "is not a compatible type" / "cannot merge" errors: when a value's type doesn't match what the library requires — this error's family across 65 libraries.

Related errors


AI-assisted analysis of moeru-ai/airi@438a067dde (2026-09-17). Data as JSON: /api/errors/c27f1a63add8909d. Report an issue: GitHub.

Appendix: source

Thrown at apps/stage-tamagotchi/src/main/services/airi/computer-use/runtime.ts:151

    })
  }

  async function readImage(input: unknown): Promise<string> {
    const { path } = v.parse(v.object({ path: v.string() }), input)
    return enqueue(async () => {
      const root = await realpath(options.storeRoot)
      const target = await realpath(path)
      const within = relative(root, target)
      if (!within || isAbsolute(within) || within === '..' || within.startsWith(`..${sep}`))
        throw new Error('Only screenshots from this AIRI computer-use store can be read.')
      const info = await stat(target)
      if (!info.isFile() || info.size > 8 * 1024 * 1024)
        throw new Error('Screenshot must be a file smaller than 8 MiB.')
      const data = await readFile(target)
      const png = data.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))
      const jpeg = data[0] === 255 && data[1] === 216 && data[2] === 255
      if (!png && !jpeg)
        throw new Error('Screenshot must be PNG or JPEG.')
      return `data:image/${png ? 'png' : 'jpeg'};base64,${data.toString('base64')}`
    })
  }

  /** Rejects queued calls, aborts active work, and waits for the owned daemon to exit. */
  async function dispose() {
    disposed = true
    abort.abort()
    await queue
    try {
      await daemon?.stop()
    }
    finally {
      await cleanupSocket()
    }
  }

  return { run, readImage, dispose }

View on GitHub (pinned to 438a067dde)