Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

CNS — Indexed Palette Layered Animation

This is my first proper file format done in a single day with out sleep. ill try to make the code and documentation as clean as i can. good luck to anybody willing to fuck with my file format. any suggestions and contributions apreciated, this is a learning project

File format specification

This document describes the CNS format. The container version is 2; this is the only public CNS version defined by this specification.

Extension: .cns Magic bytes: 43 4E 53 (ASCII "CNS", 3 bytes)

Reference implementations: cns_format.py (encoder + decoder, pure Python, stdlib only) and clipnote.js (browser parser/player).

0. Fixed constants

Not stored in the file — hard-coded into every encoder/decoder, identical for every .cns file that exists:

Constant Value
Resolution 320 × 240
Tile size 8 × 8
Tile grid 40 × 30 = 1200 tiles per layer per frame
Layer count 3 (0 = bottom, 1 = middle, 2 = top)
Palette 6 colors + transparency (7 values total)

1. Palette (format default, not stored)

Index Name HEX
0 white #ffffff
1 black #000000
2 red #ff1010
3 yellow #ffe700
4 green #008431
5 blue #0039ce
6 transparent

Every pixel in every layer is one of these 7 values. Index 6 is never rendered directly — it means "show what's beneath this pixel." The CNS file does not store RGB colors; these six RGB values are the format's default display palette. A player may offer a runtime recoloring option, but that changes display only and never changes the encoded pixel indexes or the CNS file bytes.

2. Pixel representation

Two representations exist, used at different stages:

  • Raw pixel value: a single integer 0–6. This is what layers/tiles are made of.
  • Packed tile key (pack_tile): a tile's 64 raw pixel values packed 3-bits-each, MSB-first, into a fixed 24-byte integer (64 × 3 = 192 bits = 24 bytes exactly, no padding). This packed form is used only as the dictionary-deduplication key — two tiles with identical pixels always produce identical 24-byte keys, so dedup is exact equality, not a hash with collision risk.
def pack_tile(pixels):          # 64 ints (0-6) -> 24 bytes
    value = 0
    for p in pixels:
        value = (value << 3) | p
    return value.to_bytes(24, "big")

def unpack_tile(packed):        # 24 bytes -> 64 ints (0-6)
    value = int.from_bytes(packed, "big")
    return [(value >> ((63 - i) * 3)) & 0b111 for i in range(64)]

3. Tile dictionary & compression

Step 1 — deduplicate. Walk every layer of every frame, tile by tile (40×30 grid). For each 8×8 tile, compute its 64 raw pixel values and its 24-byte packed key. If the key has been seen before (anywhere — any layer, any frame), reuse that dictionary index. Otherwise append it to the dictionary as a new entry.

Step 2 — RLE each unique tile. Each dictionary entry's 64 raw pixel values are run-length encoded as (run_length: u8, value: u8) pairs. A solid-color tile (extremely common — sky, background, empty space) compresses to a single 2-byte pair.

def rle_encode(pixels):   # 64 raw ints -> bytes of (run, value) pairs
    out = bytearray()
    i, n = 0, len(pixels)
    while i < n:
        run = 1
        while i + run < n and pixels[i + run] == pixels[i] and run < 255:
            run += 1
        out += bytes([run, pixels[i]])
        i += run
    return bytes(out)

Step 3 — deflate the whole dictionary. All RLE'd tiles, each prefixed with a uint16 length, are concatenated and passed through one zlib-wrapped DEFLATE pass (zlib.compress in Python). This is the TDIC chunk payload. FRMS uses the same zlib-wrapped DEFLATE encoding. The maximum RLE payload for one 8×8 tile is 128 bytes, so the uint16 length is sufficient.

Step 4 — frames reference tiles by index. Each frame stores, per layer, a 1200-entry grid of dictionary indices (1 byte each if the dictionary has ≤255 tiles, 2 bytes if it has ≤65,535 tiles, otherwise 4 bytes) instead of pixels. A layer that is 100% transparent for a frame stores its empty_flag and no grid. All frame/layer records are concatenated and deflated once more as the FRMS chunk.

This is why static content is nearly free: an unchanging background tile is stored once in the dictionary and referenced by the same tiny index in every single frame; deflate then squeezes the repeated indices themselves down further.

4. File layout

[ MAGIC        3 bytes   "CNS" ]
[ VERSION      2 bytes   uint16 LE ]   <- must be 2
[ HDR1 chunk ]
[ TDIC chunk ]
[ FRMS chunk ]
[ AUD1 chunk ]   <- only present if has_audio = 1

Generic chunk shape: [ id: 4 bytes ASCII ][ length: uint32 LE ][ payload: length bytes ]. The reference encoder writes chunks in HDR1, TDIC, FRMS, then optional AUD1 order. Readers should identify chunks by their four-byte IDs and reject truncated chunks rather than reading beyond the declared payload.

4.1 HDR1 — header

All integers are little-endian. Strings are a uint16 byte-length prefix followed by UTF-8 bytes, not null-terminated. The length counts encoded bytes, not characters, and must be at most 65,535. Encoders must reject longer UTF-8 strings; decoders must reject strings that exceed the containing HDR1 chunk.

Field Type Notes
framerate uint8 1–120
frame_max uint32 index of last frame; frame count = frame_max + 1
thumbnail_frame uint32 index of the frame a viewer/gallery should composite and show as this file's thumbnail; must satisfy 0 <= thumbnail_frame <= frame_max. This is just a pointer — no separate thumbnail image is stored; a reader renders it the same way it renders any other frame (composite_rgb(thumbnail_frame))
replay uint8 0 = play once and hold, 1 = loop forever
locked uint8 0 = editable, 1 = read-only in authoring tools
file_id string unique ID for this file, assigned once at creation, never changes on re-save
spinoff uint8 0/1 — is this file a fork of another CNS file
spinoff_source_file_id string the file_id of the file this was forked from; empty if spinoff = 0
original_user_name string username of the original creator; copied forward at fork time, never overwritten by later saves; empty if spinoff = 0
original_user_id string userid of the original creator; empty if spinoff = 0
has_audio uint8 0/1 — mirrors presence of the AUD1 chunk
dict_tile_count uint32 number of unique tiles in TDIC; determines the FRMS index width (1 byte if ≤255, 2 bytes if ≤65,535, otherwise 4)
user_name string username of the current/last editor — updated every save
user_id string userid of the current/last editor

file_id identifies the file itself (a stable identity, like an inode). user_id/user_name identify whoever last saved it. original_user_id/original_user_name identify who originally made the content this file is a fork of. All three are separate namespaces — never conflate them.

4.2 TDIC — tile dictionary

zlib.compress(concat of [uint16 rle_length][rle_length bytes] for each unique tile, in dictionary order 0..dict_tile_count-1).

Decoding: zlib.decompress, then walk sequentially reading length-prefixed RLE blocks until dict_tile_count tiles have been read. No separate offset table is needed — order is the dictionary index. A decoder must reject a truncated length prefix, a block extending past the decompressed stream, an RLE block that does not expand to exactly 64 values, a value outside 0–6, or unconsumed bytes after the final dictionary entry.

4.3 FRMS — frame data

For each frame 0..frame_max, for each layer 0..2, in order:

visible     : 1 byte (0/1)
empty_flag  : 1 byte (0/1)
grid        : present only if empty_flag == 0
              1200 tile-dictionary indices, row-major (ty 0..29, tx 0..39),
              each 1, 2, or 4 bytes per dict_tile_count (see HDR1); indexes
              are unsigned little-endian and must be less than dict_tile_count

The whole concatenated stream (all frames, all layers) is one zlib-wrapped DEFLATE pass. A decoder must reject truncated layer records, invalid boolean flags other than 0/1, and any tile index greater than or equal to dict_tile_count.

4.4 AUD1 — audio (optional)

Raw Ogg container bytes (Vorbis or Opus), byte-for-byte, untouched — no re-encoding. Only present if has_audio = 1. The CNS format does not define an audio codec beyond requiring an Ogg container, nor does it store a sample rate in AUD1; the Ogg stream headers carry that information. Playback starts at frame 0; if replay = 1, audio restarts in sync each time the animation loops.

5. Layers & compositing

  • Fixed 3 layers, bottom (0) → top (2).
  • Each layer has a per-frame visibility bit (in FRMS), independent of its tile data — lets you hide/show a layer across the timeline without touching its content.
  • Compositing rule: for each pixel, walk layers top → bottom; the first visible, non-transparent (index ≠ 6) pixel wins. If all three layers are transparent (or hidden) at a pixel, the result remains transparent. A viewer may display that transparency using a checker/grid background; a white background is only a viewer choice, not encoded CNS data.

The reference Python convenience method CNSFile.composite_indices() uses white (index 0) as its display fallback for fully transparent pixels. That is an API convenience, not a different file-format rule; a transparency-aware viewer can instead retain index 6 and draw its own background.

def composite_indices(file, frame_index):
    out = [6] * (320 * 240)                 # transparent until a layer paints it
    for layer in (0, 1, 2):
        px = file.decode_layer(frame_index, layer)
        if px is None:
            continue
        for i in range(320 * 240):
            if px[i] != 6:
                out[i] = px[i]
    return out

6. Spinoff / attribution model

  • Every file has its own file_id, generated once and stable forever — this is what gets referenced, never a filename or path.
  • spinoff = 1spinoff_source_file_id holds the file_id of the file this one was forked from.
  • original_user_name / original_user_id are stored inline, copied from the source file at fork time, so a viewer can show "originally by X" without needing to locate or load the (possibly missing/offline) source file.
  • On a non-spinoff file, original_user_* and user_* will typically match on first save and then user_* alone tracks future edits.

7. Validation rules

A conforming encoder/decoder must enforce:

  • 1 <= framerate <= 120
  • every raw pixel value is 0–6; a 7 is a corrupt/invalid file, not a valid fallback
  • every frame has exactly 3 layer entries in FRMS
  • 0 <= thumbnail_frame <= frame_max
  • locked == 1 → editors must open the file read-only
  • has_audio must match AUD1 chunk presence exactly
  • every tile index in FRMS must be < dict_tile_count
  • VERSION must be 2; a reader must refuse (not guess-parse) any other version

8. Worked example — building a file by hand (conceptual walkthrough)

Say you have a single frame, layer 0 only, and the top-left 8×8 tile is solid yellow (index 3):

  1. Raw pixels: [3, 3, 3, ..., 3] (64 values).
  2. Packed key (pack_tile): the 192-bit integer formed by 3 << 189 | 3 << 186 | ... | 3, rendered as 24 bytes — used only to check "have I seen this tile before?"
  3. RLE: since all 64 values are identical, this collapses to one pair: [0x40, 0x03] (run=64, value=3) — 2 bytes instead of 64.
  4. Dictionary entry: if this is the first unique tile in the file, it becomes dictionary index 0. Stored in TDIC as [0x02, 0x00] (uint16 length = 2) followed by the 2 RLE bytes — 4 bytes total, before the whole dictionary gets deflated.
  5. Frame reference: in FRMS, this tile's grid cell (tx=0, ty=0) for layer 0 just stores the byte 0x00 (dictionary index 0) — 1 byte, regardless of how large or complex the tile's original pixel content was.

A tile that's reused in 500 later frames costs zero additional dictionary space — each reuse is just one more 0x00 byte in that frame's grid, and since those bytes are usually identical to the frame before, the final deflate pass over FRMS squeezes that down even further.

9. API reference (cns_format.py)

cns.encode(
    path, frames, framerate,
    thumbnail_frame=0,
    replay=0, locked=0,
    file_id="", spinoff=0, spinoff_source_file_id="",
    original_user_name="", original_user_id="",
    user_name="", user_id="",
    audio_bytes=None,
) -> dict   # {"bytes_written", "unique_tiles", "frame_count"}

cns.decode(path) -> CNSFile

thumbnail_frame defaults to 0 (the first frame) and must be a valid frame index (0 <= thumbnail_frame <= len(frames) - 1); encode() raises ValueError otherwise.

frames is a list of:

{
  "layers": [layer0, layer1, layer2],   # each: None (fully transparent) or a flat
                                         # list of 320*240 ints (0-6), row-major
  "visible": [True, True, True],        # per-layer visibility for this frame
}

CNSFile (returned by decode) exposes all header fields as attributes (.framerate, .file_id, .spinoff_source_file_id, .original_user_id, .user_id, .thumbnail_frame, etc.) plus:

file.decode_layer(frame_index, layer_index) -> list[int] | None   # raw 0-6 pixels, or None
file.composite_indices(frame_index)          -> list[int]         # composited display pixels; white fallback
file.composite_rgb(frame_index)              -> list[(r,g,b)]     # ready to blit/display
file.thumbnail_rgb()                          -> list[(r,g,b)]     # composite_rgb(thumbnail_frame)
file.audio_bytes                              # raw Ogg bytes, or None

10. Encode and decode example

This example creates a valid two-frame CNS file with a red square on layer 1. None means a fully transparent layer. Every non-None layer is a flat, row-major array of exactly 320 * 240 palette indexes.

from pathlib import Path
import cns_format as cns

WIDTH, HEIGHT = 320, 240

def blank_layer():
    return [cns.TRANSPARENT] * (WIDTH * HEIGHT)

def red_square(x, y, size):
    pixels = blank_layer()
    for row in range(y, y + size):
        start = row * WIDTH + x
        pixels[start:start + size] = [2] * size  # palette index 2 = red
    return pixels

frames = [
    {"layers": [None, red_square(20, 30, 16), None],
     "visible": [True, True, True]},
    {"layers": [None, red_square(40, 30, 16), None],
     "visible": [True, True, True]},
]

info = cns.encode(
    "example.cns", frames, framerate=12, thumbnail_frame=1, replay=1,
    file_id="example-file", user_name="artist", user_id="user-001",
)
print(info)

decoded = cns.decode("example.cns")
assert decoded.frame_count == 2
assert decoded.thumbnail_frame == 1
assert decoded.decode_layer(0, 0) is None
assert decoded.decode_layer(0, 1)[30 * WIDTH + 20] == 2
thumbnail = decoded.thumbnail_rgb()  # composites the declared thumbnail frame

Encoding steps

For each frame and layer, a conforming encoder:

  1. Validates the layer length and every pixel value.
  2. Splits each layer into 1200 row-major 8×8 tiles.
  3. Deduplicates tiles globally using their 24-byte packed keys.
  4. RLE-encodes each unique tile and writes compressed TDIC.
  5. Writes visibility and empty flags plus dictionary indexes into FRMS.
  6. Chooses FRMS index width from dict_tile_count: 1, 2, or 4 bytes.
  7. Writes HDR1, TDIC, FRMS, and optional AUD1 after the magic/version.

To embed audio, pass an existing Ogg stream directly:

ogg_bytes = Path("master.ogg").read_bytes()
cns.encode("with-audio.cns", frames, framerate=12, audio_bytes=ogg_bytes)

cns_format.py does not convert PCM to Ogg. A converter must perform that conversion before calling cns.encode().

Decoding steps

The decoder first validates the current-format prefix:

raw = Path("example.cns").read_bytes()
assert raw[:3] == b"CNS"
assert int.from_bytes(raw[3:5], "little") == 2

It reads chunks from offset 5, parses HDR1, decompresses TDIC, and reads exactly dict_tile_count [uint16 rle_length][RLE bytes] records. It then selects the FRMS index width:

if dict_tile_count <= 255:
    index_width = 1
elif dict_tile_count <= 65535:
    index_width = 2
else:
    index_width = 4

The decoder decompresses FRMS, reads three layer records per frame, and uses the dictionary indexes to reconstruct each layer grid. Index 6 remains transparent. A viewer may draw a checker/grid background behind the result, but that background is not stored in the CNS file.

Binary helper functions

All CNS integers are little-endian:

import struct

def u16(value):
    return struct.pack("<H", value)

def u32(value):
    return struct.pack("<I", value)

def chunk(name, payload):
    assert len(name) == 4
    return name.encode("ascii") + u32(len(payload)) + payload

TDIC RLE lengths are always uint16. FRMS tile indexes are the only fields whose width changes with dictionary size.

About

a indexed animation format, like the bastard child of kwz and .clip

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages