Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 37 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,46 +1,66 @@
# kittytgp

`kittytgp` is a small pure-Python package that renders a PNG with kitty's graphics protocol using Unicode placeholders (`U+10EEEE`).
`kittytgp` is a pure-Python package that renders images and animations in the terminal with kitty's graphics protocol using Unicode placeholders (`U+10EEEE`).

It follows kitty's tmux/editor-friendly placeholder flow:
It offers high-performance rendering:

1. transmit PNG data with kitty graphics protocol
2. create a **virtual** placement with `U=1`
3. print `U+10EEEE` placeholder text colored with the image ID
1. Transmit PNG data with kitty graphics protocol
2. Create a **virtual** placement with `U=1`
3. Print `U+10EEEE` placeholder text colored with the image ID

Because the visible part is ordinary Unicode text, the image moves with the text buffer and works inside hosts such as tmux.
For GIF animations, `kittytgp` uses Kitty's advanced `a=T` command to substitute base frames dynamically, resulting in beautifully smooth terminal playbacks with zero scrolling side effects.

## Install

For basic PNG support (zero dependencies):
```bash
pip install kittytgp
```

For full multi-format (JPG, WebP, etc.) and GIF animation support:
```bash
pip install kittytgp[all]
```

## CLI

Render static images (PNG, JPG, etc.):
```bash
kittytgp plot.png
kittytgp image.jpg
kittytgp image.webp --cols 40 --rows 20
```

Useful options:
Play animations (GIF):
```bash
kittytgp animation.gif
kittytgp animation.gif --fps 24 --no-loop
```

Useful options:
```bash
kittytgp plot.png --cols 40
kittytgp plot.png --rows 20
kittytgp plot.png --cell-size 10x20
kittytgp plot.png --image-id 0x123456
kittytgp plot.png --no-newline
kittytgp animation.gif --fps 30
kittytgp animation.gif --no-loop
```

## Python API

```python
from kittytgp import render_png
from kittytgp import render_image, play_animation

render_png("plot.png")
# Render any image
render_image("plot.png")
render_image("photo.jpg", cols=40)

# Play GIF animation
play_animation("loader.gif", fps=15, loop=True)
```

Or build the bytes yourself:
Or build the bytes yourself for manual transmission:

```python
from kittytgp import build_render_bytes
Expand All @@ -50,13 +70,13 @@ payload = build_render_bytes("plot.png")

## Design notes

This package intentionally stays small:
This package is designed as a powerful terminal rendering engine:

- PNG input only
- direct transfer (`f=100` PNG payload in APC chunks)
- Unicode placeholders only
- 24-bit image IDs encoded in truecolor foreground color
- tmux passthrough only when needed
- Pillow (`PIL`) is fully optional. If active, it seamlessly handles cross-format inputs.
- Unicode placeholders are used so images move and interact fluidly within terminals like `tmux`.
- 24-bit image IDs encoded in truecolor foreground color.
- tmux passthrough support out of the box.

By default it fits the image into the current terminal while preserving aspect ratio. If the terminal cannot report cell pixel size, pass `--cell-size`, `--cols`, or `--rows`.


3 changes: 2 additions & 1 deletion kittytgp/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
__version__ = "0.0.2"

from .core import build_render_bytes, render_png
from .core import build_render_bytes, render_png, render_image
from .animation import play_animation
2 changes: 1 addition & 1 deletion kittytgp/__main__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
from .core import main
from .cli import main

raise SystemExit(main())
108 changes: 108 additions & 0 deletions kittytgp/animation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import base64
import io
import sys
import time

from .core import build_render_bytes, get_terminal_geometry, _fit_cells, normalize_image_id, _graphics_apc, _resolve_passthrough
from .formats import get_image_sequence, _read_bytes

def _transmit_frame(png_data: bytes, image_id: int, chunk_size: int, passthrough: str, stream):
"""
Transmits a replacement PNG to kitty under the same image_id.
This updates the image in-place without needing new placeholders.
"""
encoded = base64.standard_b64encode(png_data)
chunks = [encoded[i : i + chunk_size] for i in range(0, len(encoded), chunk_size)] or [b""]
for i, chunk in enumerate(chunks):
more = 1 if i < len(chunks) - 1 else 0
# a=T (transmit), f=100 (PNG), q=2 (quiet), i={id} (target image)
meta = f"a=T,f=100,q=2,i={image_id}," if i == 0 else ""
stream.write(_graphics_apc(f"{meta}m={more}", chunk, passthrough=passthrough))

def play_animation(
path_or_bytes,
fps: float = 24.0,
loop: bool = True,
cols: int | None = None,
rows: int | None = None,
image_id: int | None = None,
passthrough: str = "auto",
cell_width_px: int | None = None,
cell_height_px: int | None = None,
chunk_size: int = 4096,
newline: bool = True,
out = None,
):
stream = out or sys.stdout.buffer
data = _read_bytes(path_or_bytes)
image_id = normalize_image_id(image_id)
passthrough = _resolve_passthrough(passthrough)
frame_delay = 1.0 / fps

geometry = get_terminal_geometry(
stream.fileno() if hasattr(stream, "fileno") else getattr(sys.stdout, "fileno", lambda: 0)(),
cell_width_px=cell_width_px,
cell_height_px=cell_height_px,
)

with get_image_sequence(data) as (img, frames):
image_width_px, image_height_px = img.size

fit_cols, fit_rows = _fit_cells(
image_width_px,
image_height_px,
geometry,
cols=cols,
rows=rows,
newline=newline,
)

first_frame = True
try:
while True:
img.seek(0)
for frame in frames:
buf = io.BytesIO()
# Convert to RGBA for consistency avoiding palletted issues
frame_rgba = frame.convert("RGBA")
frame_rgba.save(buf, format="PNG")
png_data = buf.getvalue()

start_time = time.time()

if first_frame:
# Render the first frame with full placeholder grid
render_payload = build_render_bytes(
png_data,
cols=fit_cols,
rows=fit_rows,
image_id=image_id,
passthrough=passthrough,
chunk_size=chunk_size,
newline=False,
cell_width_px=cell_width_px,
cell_height_px=cell_height_px,
out=stream
)
if render_payload:
stream.write(render_payload)
stream.flush()
first_frame = False
else:
# For subsequent frames, tell kitty to replace the image data
_transmit_frame(png_data, image_id, chunk_size, passthrough, stream)
stream.flush()

elapsed = time.time() - start_time
time.sleep(max(0, frame_delay - elapsed))

if not loop:
break

except KeyboardInterrupt:
# Allow users to Ctrl+C to stop the animation gracefully
pass
finally:
if newline:
stream.write(b"\n")
stream.flush()
72 changes: 72 additions & 0 deletions kittytgp/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import argparse
import sys

from .core import _parse_cell_size, DEFAULT_CHUNK_SIZE
from .formats import load_image

def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Render images and animations with kitty Unicode placeholders")
parser.add_argument("image", help="Path to an image/animation file, or - to read from stdin")
parser.add_argument("--cols", type=int, help="target width in terminal cells")
parser.add_argument("--rows", type=int, help="target height in terminal cells")
parser.add_argument("--cell-size", type=_parse_cell_size, help="manual terminal cell size in pixels, e.g. 10x20")
parser.add_argument("--image-id", type=lambda s: int(s, 0), help="24-bit image id (decimal or 0x-prefixed hex)")
parser.add_argument(
"--passthrough",
choices=("auto", "none", "tmux"),
default="auto",
help="wrap kitty APC commands for tmux passthrough",
)
parser.add_argument("--chunk-size", type=int, default=DEFAULT_CHUNK_SIZE, help="base64 bytes per graphics chunk")
parser.add_argument("--no-newline", action="store_true", help="do not print a trailing newline after the image")
parser.add_argument("--fps", type=float, default=24.0, help="frames per second for animations")
parser.add_argument("--loop", action=argparse.BooleanOptionalAction, default=True, help="loop animations")
return parser

def _load_cli_input(arg: str) -> bytes | str:
return sys.stdin.buffer.read() if arg == "-" else arg

def main(argv: list[str] | None = None) -> int:
parser = build_arg_parser()
args = parser.parse_args(argv)
cell_width_px = cell_height_px = None
if args.cell_size is not None:
cell_width_px, cell_height_px = args.cell_size

input_data = _load_cli_input(args.image)

try:
# load_image converts inputs to PNG bytes or detects animations
png_data, is_animated = load_image(input_data)

if is_animated:
from .animation import play_animation
play_animation(
input_data,
fps=args.fps,
loop=args.loop,
cols=args.cols,
rows=args.rows,
image_id=args.image_id,
passthrough=args.passthrough,
cell_width_px=cell_width_px,
cell_height_px=cell_height_px,
chunk_size=args.chunk_size,
newline=not args.no_newline,
)
else:
from .core import render_png
render_png(
png_data,
cols=args.cols,
rows=args.rows,
image_id=args.image_id,
passthrough=args.passthrough,
cell_width_px=cell_width_px,
cell_height_px=cell_height_px,
chunk_size=args.chunk_size,
newline=not args.no_newline,
)
except Exception as exc:
parser.exit(1, f"kittytgp: {exc}\n")
return 0
71 changes: 29 additions & 42 deletions kittytgp/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -358,46 +358,33 @@ def _parse_cell_size(value: str) -> tuple[int, int]:
return width, height


def build_arg_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Render a PNG with kitty Unicode placeholders")
parser.add_argument("png", help="Path to a PNG file, or - to read PNG bytes from stdin")
parser.add_argument("--cols", type=int, help="target width in terminal cells")
parser.add_argument("--rows", type=int, help="target height in terminal cells")
parser.add_argument("--cell-size", type=_parse_cell_size, help="manual terminal cell size in pixels, e.g. 10x20")
parser.add_argument("--image-id", type=lambda s: int(s, 0), help="24-bit image id (decimal or 0x-prefixed hex)")
parser.add_argument(
"--passthrough",
choices=("auto", "none", "tmux"),
default="auto",
help="wrap kitty APC commands for tmux passthrough",
def render_image(
path_or_bytes: str | os.PathLike[str] | bytes,
*,
cols: int | None = None,
rows: int | None = None,
image_id: int | None = None,
passthrough: str = "auto",
cell_width_px: int | None = None,
cell_height_px: int | None = None,
chunk_size: int = DEFAULT_CHUNK_SIZE,
newline: bool = True,
out: BinaryIO | None = None,
) -> int:
"""
Renders an image in the terminal. Uses Pillow to convert formats if necessary.
"""
from .formats import load_image
png_data, _ = load_image(path_or_bytes)
return render_png(
png_data,
cols=cols,
rows=rows,
image_id=image_id,
passthrough=passthrough,
cell_width_px=cell_width_px,
cell_height_px=cell_height_px,
chunk_size=chunk_size,
newline=newline,
out=out,
)
parser.add_argument("--chunk-size", type=int, default=DEFAULT_CHUNK_SIZE, help="base64 bytes per graphics chunk")
parser.add_argument("--no-newline", action="store_true", help="do not print a trailing newline after the image")
return parser


def _load_cli_png(arg: str) -> bytes | str:
return sys.stdin.buffer.read() if arg == "-" else arg


def main(argv: list[str] | None = None) -> int:
parser = build_arg_parser()
args = parser.parse_args(argv)
cell_width_px = cell_height_px = None
if args.cell_size is not None:
cell_width_px, cell_height_px = args.cell_size
try:
render_png(
_load_cli_png(args.png),
cols=args.cols,
rows=args.rows,
image_id=args.image_id,
passthrough=args.passthrough,
cell_width_px=cell_width_px,
cell_height_px=cell_height_px,
chunk_size=args.chunk_size,
newline=not args.no_newline,
)
except Exception as exc:
parser.exit(1, f"kittytgp: {exc}\n")
return 0
Loading