8bitlenser/lenser/cli.py
2026-07-03 19:35:35 -07:00

138 lines
7.4 KiB
Python

"""Headless entry point: convert an image, write a disk image and/or a preview PNG."""
from __future__ import annotations
import argparse
import sys
from PIL import Image
from . import imageprep
from .convert import MODES, convert_image, render_preview
from .palette import COLOR_NAMES
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="8bitlenser", description=__doc__)
p.add_argument("image", help="source image (png/jpg/gif/bmp/webp)")
p.add_argument("-o", "--output", help="disk image path (.d64/.d71/.d81/.atr)")
p.add_argument("--platform", default="c64",
choices=["c64", "atari", "apple", "ti99", "coco", "bbc",
"coleco", "a2600", "intv", "vic20", "spectrum", "a5200",
"a7800", "c128", "c16", "plus4", "cpc", "coco3",
"nes", "iigs", "pet2001", "pet4032", "pet8032",
"superpet", "sms", "amiga", "ansi"],
help="target machine (coleco = ColecoVision/Adam, "
"a2600 = Atari 2600/VCS, intv = Mattel Intellivision, "
"vic20 = Commodore VIC-20, spectrum = Sinclair ZX Spectrum, "
"a5200 = Atari 5200, a7800 = Atari 7800, "
"c128 = Commodore 128 VDC 80-column, "
"c16 = Commodore 16 TED hires, "
"plus4 = Commodore Plus/4 TED hires, "
"cpc = Amstrad CPC, "
"coco3 = Tandy CoCo 3 GIME, "
"nes = Nintendo NES/Famicom, "
"iigs = Apple IIGS Super Hi-Res, "
"pet2001/pet4032 = 40-col PET/CBM, "
"pet8032/superpet = 80-col PET/CBM, "
"sms = Sega Master System, "
"amiga = Commodore Amiga)")
p.add_argument("-m", "--mode", default="auto",
choices=["auto", *MODES, "gr15", "gr9", "gr8", "gr15dli",
"hgr_mono", "dhgr", "hgr_color", "gm2",
"pmode4", "pmode3",
"mode0", "mode1", "mode2", "mode5", "stic", "pf_il",
"c160", "color", "hicolor", "gr16", "gr4", "bg",
"shr", "lowres", "80x25", "80x50"],
help="display mode (c64: hires/multicolor/fli/interlace/mono; "
"atari: gr15/gr9/gr8/gr15dli; apple: hgr_mono/dhgr; "
"ti99: gm2; coco: pmode4/pmode3; bbc: mode0/1/2/5; "
"intv: stic; ansi: 80x25/80x50)")
p.add_argument("-f", "--format", default=None,
choices=["d64", "d71", "d81", "crt", "atr", "car", "ccc",
"ssd", "col", "a26", "int", "a0", "sna", "a52", "a78",
"prg", "nes", "sms", "adf", "ans"],
help="output format: c64 disk d64/d71/d81 or cartridge crt; "
"atari disk atr or cartridge car (default: from -o ext)")
p.add_argument("-p", "--palette", default="colodore",
choices=["colodore", "pepto"])
p.add_argument("-d", "--dither", default=None,
choices=["bayer", "bluenoise", "yliluoma", "floyd",
"atkinson", "stucki", "jarvis", "sierra",
"sierra_lite", "burkes", "riemersma",
"ostromoukhov", "none"],
help="dithering (default: atkinson; none for intv -- "
"per-platform defaults that suit each format)")
p.add_argument("--mono-base", default="grayscale",
choices=["grayscale", *COLOR_NAMES],
help="base colour for 'mono' mode (default greyscale)")
p.add_argument("-a", "--aspect", default="fit",
choices=["fit", "fill", "stretch"])
p.add_argument("--video", default="pal", choices=["pal", "ntsc"],
help="target video standard (affects the FLI viewer timing)")
p.add_argument("--intensive", action="store_true",
help="exhaustive background search + slower, higher-quality passes")
p.add_argument("--brightness", type=float, default=1.0)
p.add_argument("--contrast", type=float, default=1.0)
p.add_argument("--saturation", type=float, default=1.0)
p.add_argument("--gamma", type=float, default=1.0)
p.add_argument("--tint", type=float, default=0.0,
help="hue rotation in degrees (-180..180)")
p.add_argument("--red", type=float, default=1.0, help="red level (gain)")
p.add_argument("--green", type=float, default=1.0, help="green level (gain)")
p.add_argument("--blue", type=float, default=1.0, help="blue level (gain)")
p.add_argument("--preview", help="also write a PNG preview to this path")
p.add_argument("--disk-name", default=None, help="disk + viewer name (PETSCII)")
p.add_argument("--display", default="key",
choices=["forever", "key", "seconds"],
help="how long the viewer holds the picture (C64): forever, "
"until a key, or --seconds N then exit to BASIC")
p.add_argument("--seconds", type=int, default=10,
help="seconds to display when --display seconds")
p.add_argument("--viewer", default="unified",
choices=["unified", "separate"],
help="unified = one self-contained file; separate = viewer "
"binary + a standalone 'data' image file (C64 disk)")
return p
def main(argv=None) -> int:
args = build_parser().parse_args(argv)
prep = imageprep.PrepOptions(
aspect=args.aspect, brightness=args.brightness, contrast=args.contrast,
saturation=args.saturation, gamma=args.gamma,
tint=args.tint, red=args.red, green=args.green, blue=args.blue,
)
from . import platforms
# Atkinson is the default dither (only used when -d is not given): its lighter
# error diffusion bleeds less across constrained colour cells and keeps clean
# local contrast. The one exception is the Intellivision, whose 64-tile GRAM
# dictionary bloats under any diffusion, so it defaults to no dithering.
_def_dither = {"intv": "none"}
dither = args.dither or _def_dither.get(args.platform, "atkinson")
conv = platforms.convert(args.platform, args.image, args.mode, args.palette,
dither, args.intensive, prep, args.mono_base)
_dsz = len(conv.data) if isinstance(conv.data, (bytes, bytearray)) else None
print(f"platform={args.platform} mode={conv.mode} mean dE={conv.error:.2f}"
+ (f" data={_dsz}B" if _dsz is not None else ""))
if args.preview:
rgb = render_preview(conv, args.palette, scale=2)
Image.fromarray(rgb, "RGB").save(args.preview)
print(f"wrote preview {args.preview}")
if args.output:
path = platforms.export(args.platform, conv, args.output, args.format,
args.image, args.video, display=args.display,
seconds=args.seconds, layout=args.viewer)
kind = "ANSI art" if args.platform == "ansi" else "disk image"
print(f"wrote {kind} {path}")
if not args.output and not args.preview:
print("nothing to do: pass -o DISK and/or --preview PNG", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())