# pics.2bit.image

> Published by [2bit.pics](https://lexicon.garden/identity/did:plc:sbmntiflmknij2axeck3w6u7)

✓ This is the authoritative definition for this NSID.

## Links

- [View on Lexicon Garden](https://lexicon.garden/lexicon/did:plc:sbmntiflmknij2axeck3w6u7/pics.2bit.image)
- [Documentation](https://lexicon.garden/lexicon/did:plc:sbmntiflmknij2axeck3w6u7/pics.2bit.image/docs)
- [Examples](https://lexicon.garden/lexicon/did:plc:sbmntiflmknij2axeck3w6u7/pics.2bit.image/examples)

## Definitions

### `pics.2bit.image#mono`

**Type**: `object`

A four-shade photo: one palette and a pack of one or more 2bpp exposures of the same subject. One exposure is an ordinary photo. Several are what the Game Boy Camera scene calls an HDR photo: average the exposures' pixel values into a fractional level between 0 and 3, then paint it by interpolating between the two adjacent entries of `colors` - N exposures give 3N+1 tones along the same four-color ramp, so nothing here needs a longer palette. The exposure count is not stored: it is (blob size - 16) / 3584, and a pack whose size doesn't divide out is malformed.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `colors` | `array` | Yes | Four 24-bit RGB colors (0 to 0xFFFFFF) corresponding to 2bpp pixel values 0 through 3, where 0 is lightest and 3 is darkest. One palette for the whole stack - every exposure is the same scene, so per-exposure palettes could only disagree. Same values and order as the legacy pics.2bit.feed.photo#colors, so migrating an old record is a move, not a transformation. |
| `planes` | `blob` | Yes | The plane pack: a 16-byte header followed by whole 128x112 2bpp planes, one per exposure, in the order they were shot. Each plane is 3,584 bytes of Game Boy Camera tile data - 16x14 tiles in row-major order, 16 bytes per tile (8 rows of low+high bit-plane bytes, MSB-first), pixel value 0 lightest and 3 darkest. The header is, byte by byte: 0x89 (high bit set, so no sniffer reads the pack as text), '2BPP' (0x32 0x42 0x50 0x50), a header-format version (0x01), a kind byte (0x01, a plane pack), tilesX (0x10, 16), tilesY (0x0E, 14), topRows (0x00, meaningful only for frame blobs), four reserved zero bytes, then 0x0D 0x0A (CRLF, which catches line-ending mangling in transit - the same trick as PNG's signature). The leading 0x89 also stops content sniffers misreading a pack that opens on a dark region: a black tile is 16 bytes of 0xFF, which is a valid MPEG audio frame sync, and a PDS that sniffs uploads records whatever type it guesses. tilesX and tilesY restate what this schema already fixes; a parser must refuse a pack whose header disagrees with it rather than trusting either side. No `accept` constraint on purpose: a sniffing PDS records the MIME type it guesses, not the one the uploader declares, so any allowed list here would reject photos based on where their author's repo is hosted. Well-formedness comes from the size instead - (size - 16) must divide by 3,584 - which no sniffer can affect. |

### `pics.2bit.image#levels`

**Type**: `array`

One channel's intensity ramp, darkest to lightest: a pixel of value v paints at intensity levels[3 - v], so value 3 (darkest) takes the first entry and value 0 the last. Absent means the even [0, 85, 170, 255]. This is the ramp as the Game Boy Camera tooling writes it - note it runs the opposite way to `colors`, which lists lightest first; reversing one to match the other would trade a mismatch with every existing trichrome tool for a mismatch inside this schema.

**Items**:

Type: `integer`

**Constraints**: minLength: 4, maxLength: 4

### `pics.2bit.image#trichrome`

**Type**: `object`

A color photo recombined from color-filtered 2bpp exposures, the way a three-color plate is - and the Game Boy Camera community's trichrome. Each exposure is a set of red, green and blue filtered planes, optionally plus an unfiltered neutral pass. To composite one exposure: paint each color plane through its channel's ramp into its own output channel and add the three, clamping each channel at 255; then, when a neutral plane exists and `blend` says to use it, paint the neutral plane as gray - each level drives all three channels equally - and composite it over the sum. Several exposures composite independently and are averaged, every exposure counting equally - a bracketed (HDR) trichrome. The exposure count is stored nowhere: `exposures` must have exactly (blob size - 16) / (channels x 3584) entries, and a record where the two disagree is malformed.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `planes` | `blob` | Yes | The plane pack, in the same container as pics.2bit.image#mono `planes`: the 16-byte header, then `channels` planes per exposure in red, green, blue, neutral order, exposures in the order they were shot. Every plane is a whole 3,584-byte 128x112 2bpp image; there is no per-plane framing inside the pack, so plane i of exposure e begins at byte 16 + (e * channels + i) * 3584. |
| `channels` | `integer` | Yes | Planes per exposure in the pack: 3 for red/green/blue, 4 when every exposure also carries a neutral pass. Lives on the record rather than in the blob header so the AppView can validate the pack's size without fetching it - blob refs carry their byte size inline. |
| `exposures` | `array` | Yes | Per-exposure develop settings, positionally parallel to the pack: entry e describes the planes of exposure e. Parallel-by-position rather than nested, because the planes live in one blob - the count check against the blob's size is what keeps the two from drifting apart. |

### `pics.2bit.image#separations`

**Type**: `object`

How one trichrome exposure's planes develop: an intensity ramp per channel and the neutral plane's blend mode. Everything is optional - an empty object means even ramps and `normal` - so a straight-from-the-camera exposure stores nothing.

| Property | Type | Required | Description |
|----------|------|----------|-------------|
| `red` | `ref` → `#levels` | No |  |
| `blue` | `ref` → `#levels` | No |  |
| `blend` | `string` | No | How the neutral plane composites over the summed color channels. `normal` discards the neutral pass entirely rather than drawing it - drawing a gray plane source-over would only bury the color underneath, so there is no mode that does. Absent means `normal`, so a neutral plane stored without a blend is never rendered. The rest are the standard separable blend modes under their canvas globalCompositeOperation names (`dodge` is color-dodge, `addition` is lighter, `burn` is color-burn, `softlight` and `hardlight` are soft-light and hard-light). An unknown value should be treated as `normal` rather than refused on read, so new modes can be added without breaking older clients. |
| `green` | `ref` → `#levels` | No |  |
| `neutral` | `ref` → `#levels` | No | The neutral plane's ramp. Inert when the pack carries no neutral plane (`channels` is 3) - ignored rather than refused, like `levels` anywhere a plane is absent. |

## Raw Schema

```json
{
  "id": "pics.2bit.image",
  "defs": {
    "mono": {
      "type": "object",
      "required": [
        "colors",
        "planes"
      ],
      "properties": {
        "colors": {
          "type": "array",
          "items": {
            "type": "integer",
            "maximum": 16777215,
            "minimum": 0
          },
          "maxLength": 4,
          "minLength": 4,
          "description": "Four 24-bit RGB colors (0 to 0xFFFFFF) corresponding to 2bpp pixel values 0 through 3, where 0 is lightest and 3 is darkest. One palette for the whole stack - every exposure is the same scene, so per-exposure palettes could only disagree. Same values and order as the legacy pics.2bit.feed.photo#colors, so migrating an old record is a move, not a transformation."
        },
        "planes": {
          "type": "blob",
          "maxSize": 1048576,
          "description": "The plane pack: a 16-byte header followed by whole 128x112 2bpp planes, one per exposure, in the order they were shot. Each plane is 3,584 bytes of Game Boy Camera tile data - 16x14 tiles in row-major order, 16 bytes per tile (8 rows of low+high bit-plane bytes, MSB-first), pixel value 0 lightest and 3 darkest. The header is, byte by byte: 0x89 (high bit set, so no sniffer reads the pack as text), '2BPP' (0x32 0x42 0x50 0x50), a header-format version (0x01), a kind byte (0x01, a plane pack), tilesX (0x10, 16), tilesY (0x0E, 14), topRows (0x00, meaningful only for frame blobs), four reserved zero bytes, then 0x0D 0x0A (CRLF, which catches line-ending mangling in transit - the same trick as PNG's signature). The leading 0x89 also stops content sniffers misreading a pack that opens on a dark region: a black tile is 16 bytes of 0xFF, which is a valid MPEG audio frame sync, and a PDS that sniffs uploads records whatever type it guesses. tilesX and tilesY restate what this schema already fixes; a parser must refuse a pack whose header disagrees with it rather than trusting either side. No `accept` constraint on purpose: a sniffing PDS records the MIME type it guesses, not the one the uploader declares, so any allowed list here would reject photos based on where their author's repo is hosted. Well-formedness comes from the size instead - (size - 16) must divide by 3,584 - which no sniffer can affect."
        }
      },
      "description": "A four-shade photo: one palette and a pack of one or more 2bpp exposures of the same subject. One exposure is an ordinary photo. Several are what the Game Boy Camera scene calls an HDR photo: average the exposures' pixel values into a fractional level between 0 and 3, then paint it by interpolating between the two adjacent entries of `colors` - N exposures give 3N+1 tones along the same four-color ramp, so nothing here needs a longer palette. The exposure count is not stored: it is (blob size - 16) / 3584, and a pack whose size doesn't divide out is malformed."
    },
    "levels": {
      "type": "array",
      "items": {
        "type": "integer",
        "maximum": 255,
        "minimum": 0
      },
      "maxLength": 4,
      "minLength": 4,
      "description": "One channel's intensity ramp, darkest to lightest: a pixel of value v paints at intensity levels[3 - v], so value 3 (darkest) takes the first entry and value 0 the last. Absent means the even [0, 85, 170, 255]. This is the ramp as the Game Boy Camera tooling writes it - note it runs the opposite way to `colors`, which lists lightest first; reversing one to match the other would trade a mismatch with every existing trichrome tool for a mismatch inside this schema."
    },
    "trichrome": {
      "type": "object",
      "required": [
        "channels",
        "planes",
        "exposures"
      ],
      "properties": {
        "planes": {
          "type": "blob",
          "maxSize": 1048576,
          "description": "The plane pack, in the same container as pics.2bit.image#mono `planes`: the 16-byte header, then `channels` planes per exposure in red, green, blue, neutral order, exposures in the order they were shot. Every plane is a whole 3,584-byte 128x112 2bpp image; there is no per-plane framing inside the pack, so plane i of exposure e begins at byte 16 + (e * channels + i) * 3584."
        },
        "channels": {
          "type": "integer",
          "maximum": 4,
          "minimum": 3,
          "description": "Planes per exposure in the pack: 3 for red/green/blue, 4 when every exposure also carries a neutral pass. Lives on the record rather than in the blob header so the AppView can validate the pack's size without fetching it - blob refs carry their byte size inline."
        },
        "exposures": {
          "type": "array",
          "items": {
            "ref": "#separations",
            "type": "ref"
          },
          "minLength": 1,
          "description": "Per-exposure develop settings, positionally parallel to the pack: entry e describes the planes of exposure e. Parallel-by-position rather than nested, because the planes live in one blob - the count check against the blob's size is what keeps the two from drifting apart."
        }
      },
      "description": "A color photo recombined from color-filtered 2bpp exposures, the way a three-color plate is - and the Game Boy Camera community's trichrome. Each exposure is a set of red, green and blue filtered planes, optionally plus an unfiltered neutral pass. To composite one exposure: paint each color plane through its channel's ramp into its own output channel and add the three, clamping each channel at 255; then, when a neutral plane exists and `blend` says to use it, paint the neutral plane as gray - each level drives all three channels equally - and composite it over the sum. Several exposures composite independently and are averaged, every exposure counting equally - a bracketed (HDR) trichrome. The exposure count is stored nowhere: `exposures` must have exactly (blob size - 16) / (channels x 3584) entries, and a record where the two disagree is malformed."
    },
    "separations": {
      "type": "object",
      "properties": {
        "red": {
          "ref": "#levels",
          "type": "ref"
        },
        "blue": {
          "ref": "#levels",
          "type": "ref"
        },
        "blend": {
          "type": "string",
          "description": "How the neutral plane composites over the summed color channels. `normal` discards the neutral pass entirely rather than drawing it - drawing a gray plane source-over would only bury the color underneath, so there is no mode that does. Absent means `normal`, so a neutral plane stored without a blend is never rendered. The rest are the standard separable blend modes under their canvas globalCompositeOperation names (`dodge` is color-dodge, `addition` is lighter, `burn` is color-burn, `softlight` and `hardlight` are soft-light and hard-light). An unknown value should be treated as `normal` rather than refused on read, so new modes can be added without breaking older clients.",
          "knownValues": [
            "normal",
            "lighten",
            "screen",
            "dodge",
            "addition",
            "darken",
            "multiply",
            "burn",
            "overlay",
            "softlight",
            "hardlight",
            "difference",
            "exclusion"
          ]
        },
        "green": {
          "ref": "#levels",
          "type": "ref"
        },
        "neutral": {
          "ref": "#levels",
          "type": "ref",
          "description": "The neutral plane's ramp. Inert when the pack carries no neutral plane (`channels` is 3) - ignored rather than refused, like `levels` anywhere a plane is absent."
        }
      },
      "description": "How one trichrome exposure's planes develop: an intensity ramp per channel and the neutral plane's blend mode. Everything is optional - an empty object means even ramps and `normal` - so a straight-from-the-camera exposure stores nothing."
    }
  },
  "$type": "com.atproto.lexicon.schema",
  "lexicon": 1
}
```
