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

588 lines
24 KiB
Python

"""Slideshow: pack several converted images onto one drive image with an
on-machine viewer that steps through them.
A slideshow is locked to a single platform. Each slide carries its own image
adjustments (the same PrepOptions as a normal conversion) plus its own mode /
palette / dither, so the queue can freely mix e.g. hires and multicolor on the
C64. The viewer advances on a keypress, after a number of seconds, or both.
This module owns:
* the data model (SlideItem / Slideshow),
* conversion of an item to bytes (delegating to platforms.convert),
* the storage engine that sizes a slideshow against a disk format and refuses
to overfill it,
* the C64 build (assemble the slideshow viewer + write the data files).
Only disk-image platforms can host a slideshow (a cartridge or snapshot is a
single fixed payload). C64 is implemented here as the reference; other disk
platforms register via SLIDESHOW_PLATFORMS as they gain viewers.
"""
from __future__ import annotations
import math
import os
from dataclasses import dataclass, field, replace
from . import diskimage, imageprep, platforms
from .convert.base import Conversion
# Platforms whose output is a real multi-file filesystem image AND that have a
# slideshow viewer. C64 first; others are added as their viewers land.
SLIDESHOW_PLATFORMS = ("c64", "c128", "atari", "bbc", "apple", "iigs", "amiga")
# Advance behaviours (how the viewer leaves a slide). "forever" is intentionally
# absent -- a slideshow that never advances is just a single-image viewer.
ADVANCE_MODES = ("key", "seconds", "both")
@dataclass
class SlideItem:
"""One picture in the queue, with everything needed to reproduce its bytes."""
source_path: str
mode: str = "auto"
palette: str = "colodore"
dither: str = "atkinson"
mono_base: str = "grayscale"
prep: imageprep.PrepOptions = field(default_factory=imageprep.PrepOptions)
@dataclass
class Slideshow:
platform: str = "c64"
disk_format: str = "d64"
advance: str = "both"
seconds: int = 10
loop: bool = True
items: list[SlideItem] = field(default_factory=list)
# --------------------------------------------------------------------------- #
# conversion
# --------------------------------------------------------------------------- #
def convert_item(show: Slideshow, item: SlideItem,
intensive: bool = False) -> Conversion:
"""Convert one slide to a Conversion (bytes + viewer key + load address)."""
return platforms.convert(show.platform, item.source_path, item.mode,
item.palette, item.dither, intensive, item.prep,
item.mono_base)
# --------------------------------------------------------------------------- #
# storage engine
# --------------------------------------------------------------------------- #
# Each disk platform maps to a "filesystem" capacity model. CBM-DOS (c1541 d64/
# d71/d81, shared by the C64 and C128) counts 254-byte data blocks and caps the
# directory; other filesystems get their own model as they are added.
_FS_CBM = "cbm"
_FS_ATR = "atr"
_FS_DFS = "dfs"
_FS_APPLE = "apple"
_FS_IIGS = "iigs"
_FS_AMIGA = "amiga"
_PLATFORM_FS = {
"c64": _FS_CBM,
"c128": _FS_CBM,
"atari": _FS_ATR,
"bbc": _FS_DFS,
"apple": _FS_APPLE,
"iigs": _FS_IIGS,
"amiga": _FS_AMIGA,
}
# Per-format usable capacity (254-byte blocks) and directory-entry cap for CBM-DOS.
_CBM_DIR_CAP = {"d64": 144, "d71": 144, "d81": 296}
# Atari single-density ATR: 720 x 128-byte sectors. The self-booting viewer
# reserves the first few sectors (fixed) and the raw images follow it; there is
# no filesystem/directory, so the only cap is the sector count.
_ATR_TOTAL_SECTORS = 720
_ATR_BOOT_SECTORS = 8
_ATR_BLOCK = 128
# Acorn DFS single-sided: 800 x 256-byte sectors, sectors 0-1 are the catalogue,
# at most 31 files.
_DFS_TOTAL_SECTORS = 800
_DFS_CATALOGUE_SECTORS = 2
_DFS_BLOCK = 256
_DFS_MAX_FILES = 31
# Apple II HGR slideshow loads every image into RAM at once ($4000-$BFFF), so the
# binding limit is that 128-page (32K) buffer -- 4 HGR images of 32 pages each --
# not the disk. Measured in 256-byte pages.
_APPLE_BLOCK = 256
_APPLE_BUFFER_PAGES = 0x80 # $4000-$BFFF
# Apple IIgs: a 5.25" ProDOS-order .dsk (35 x 16 x 256 = 560 sectors). Each 32K
# SHR image is read into bank 0 then banked to extended RAM; the binding limit is
# the small disk (boot + stage2 + N*128 sectors).
_IIGS_TOTAL_SECTORS = 560
_IIGS_VIEWER_SECTORS = 4 # boot + stage2 (+ slack)
_IIGS_BLOCK = 256
_IIGS_MAX_IMAGES = 4
# Amiga: the slideshow loads every image into chip RAM ($20000-$7FFFF) at once
# and cycles them there, so the 384K RAM region (not the 880K floppy) is the
# binding limit. Measured in 512-byte sectors.
_AMIGA_BLOCK = 512
_AMIGA_RAM_SECTORS = (0x80000 - 0x20000) // 512 # 768
def image_nbytes(conv: Conversion) -> int:
"""On-disk byte size of one slide's picture data (Amiga's conv.data is a dict
of planes/colours, so its size is the assembled copper+bitplanes blob)."""
if isinstance(conv.data, dict):
from .amiga.viewer import image_blob
d = conv.data
return len(image_blob(d["planes"], d["colors"], d["nplanes"], d["ham"]))
return len(conv.data)
def _platform_fs(platform: str) -> str:
fs = _PLATFORM_FS.get(platform)
if fs is None:
raise NotImplementedError(f"slideshow storage not implemented for {platform}")
return fs
@dataclass
class Budget:
used_blocks: int
total_blocks: int
files: int
file_cap: int
fits: bool
reason: str = ""
block_size: int = 254 # bytes per block/sector (CBM 254, ATR 128)
@property
def used_bytes(self) -> int:
return self.used_blocks * self.block_size
@property
def total_bytes(self) -> int:
return self.total_blocks * self.block_size
def _cbm_blocks(prg_len: int) -> int:
"""CBM blocks a PRG of prg_len bytes (incl. its 2-byte load address) uses."""
return max(1, math.ceil(prg_len / 254))
def item_blocks(platform: str, fmt: str, data_len: int) -> int:
"""Blocks/sectors one picture file occupies."""
fs = _platform_fs(platform)
if fs == _FS_CBM:
return _cbm_blocks(data_len + 2) # + 2-byte PRG load address
if fs == _FS_ATR:
return max(1, math.ceil(data_len / _ATR_BLOCK))
if fs == _FS_DFS:
return max(1, math.ceil(data_len / _DFS_BLOCK))
if fs == _FS_APPLE:
return max(1, math.ceil(data_len / _APPLE_BLOCK))
if fs == _FS_IIGS:
return max(1, math.ceil(data_len / _IIGS_BLOCK))
if fs == _FS_AMIGA:
return max(1, math.ceil(data_len / _AMIGA_BLOCK))
raise NotImplementedError(f"slideshow storage not implemented for {platform}")
def viewer_blocks(platform: str, fmt: str, viewer_len: int) -> int:
"""Blocks/sectors the boot viewer itself occupies."""
fs = _platform_fs(platform)
if fs == _FS_CBM:
return _cbm_blocks(viewer_len)
if fs == _FS_ATR:
return _ATR_BOOT_SECTORS # fixed boot-sector reservation
if fs == _FS_DFS:
return 2 * max(1, math.ceil(viewer_len / _DFS_BLOCK)) # !BOOT + PIC copies
if fs == _FS_APPLE:
return 0 # boot loader lives at $0800, not the buffer
if fs == _FS_IIGS:
return _IIGS_VIEWER_SECTORS
if fs == _FS_AMIGA:
return 0 # boot block isn't in the image RAM region
raise NotImplementedError(f"slideshow storage not implemented for {platform}")
def capacity_blocks(platform: str, fmt: str) -> int:
fs = _platform_fs(platform)
if fs == _FS_CBM:
if fmt not in diskimage.BLOCKS_FREE:
raise ValueError(f"{fmt} is not a slideshow disk format for {platform}")
return diskimage.BLOCKS_FREE[fmt]
if fs == _FS_ATR:
return _ATR_TOTAL_SECTORS
if fs == _FS_DFS:
return _DFS_TOTAL_SECTORS - _DFS_CATALOGUE_SECTORS
if fs == _FS_APPLE:
return _APPLE_BUFFER_PAGES # RAM buffer, not the disk
if fs == _FS_IIGS:
return _IIGS_TOTAL_SECTORS
if fs == _FS_AMIGA:
return _AMIGA_RAM_SECTORS
raise NotImplementedError(f"slideshow storage not implemented for {platform}")
def dir_cap(platform: str, fmt: str) -> int:
fs = _platform_fs(platform)
if fs == _FS_CBM:
return _CBM_DIR_CAP.get(fmt, 144)
if fs == _FS_ATR:
return _ATR_TOTAL_SECTORS # no directory; bound by sectors
if fs == _FS_DFS:
return _DFS_MAX_FILES
if fs == _FS_APPLE:
return _APPLE_BUFFER_PAGES # bound by RAM, not a directory
if fs == _FS_IIGS:
return _IIGS_TOTAL_SECTORS
if fs == _FS_AMIGA:
return _AMIGA_RAM_SECTORS
raise NotImplementedError(f"slideshow storage not implemented for {platform}")
def _block_size(platform: str) -> int:
fs = _platform_fs(platform)
if fs == _FS_ATR:
return _ATR_BLOCK
if fs == _FS_DFS:
return _DFS_BLOCK
if fs == _FS_APPLE:
return _APPLE_BLOCK
if fs == _FS_IIGS:
return _IIGS_BLOCK
if fs == _FS_AMIGA:
return _AMIGA_BLOCK
return 254
def budget(platform: str, fmt: str, data_lens: list[int],
viewer_len: int) -> Budget:
"""Size a slideshow against a disk format.
``data_lens`` are the per-image payload lengths (len(conv.data)); ``viewer_len``
is the assembled viewer length. The viewer plus one file per image must fit
both the block/sector capacity and (where applicable) the directory cap.
"""
total = capacity_blocks(platform, fmt)
cap = dir_cap(platform, fmt)
used = (viewer_blocks(platform, fmt, viewer_len)
+ sum(item_blocks(platform, fmt, n) for n in data_lens))
files = 1 + len(data_lens) # viewer + one per image
fits = used <= total and files <= cap
reason = ""
if used > total:
reason = f"{used} blocks needed, {fmt} holds {total}"
elif files > cap:
reason = f"{files} files exceeds the {fmt} directory ({cap})"
return Budget(used, total, files, cap, fits, reason, _block_size(platform))
# --------------------------------------------------------------------------- #
# build (C64 reference)
# --------------------------------------------------------------------------- #
def _mode_byte(conv: Conversion) -> int:
"""Per-image VIC setup selector for the simple slideshow viewer.
0 = hires/mono (single bitmap + screen), 1 = multicolor (+ colour RAM + bg).
"""
return 1 if conv.viewer == "multicolor" else 0
# viewer keys that each slideshow engine flavor accepts
_SIMPLE_VIEWERS = {"hires", "multicolor"} # mono uses the "hires" viewer
_FLI_VIEWERS = {"fli", "fli_ntsc"}
_INTERLACE_VIEWERS = {"interlace"}
def slideshow_flavor(convs: list[Conversion]) -> str:
"""Pick the engine for a queue, or raise if its modes can't share a viewer.
A slideshow must be all simple (hires/multicolor/mono), all FLI, or all
interlace -- FLI/IFLI each need their own raster engine and memory map, so
they can't be mixed with the simple modes or each other.
"""
viewers = {c.viewer for c in convs}
if viewers <= _SIMPLE_VIEWERS:
return "simple"
if viewers <= _FLI_VIEWERS:
return "fli"
if viewers <= _INTERLACE_VIEWERS:
return "interlace"
raise ValueError(
"a slideshow must be all hires/multicolor/mono, all FLI, or all "
"interlace -- these modes cannot be mixed on one disc")
def data_filename(i: int) -> str:
"""Disk filename for slide ``i`` (two PETSCII digits the viewer rebuilds)."""
return f"{i:02d}"
def supports_slideshow(platform: str) -> bool:
return platform in SLIDESHOW_PLATFORMS
# Disk formats offered for a slideshow on each platform.
_SS_FORMATS = {
"c64": ["d64", "d71", "d81"],
"c128": ["d64"],
"atari": ["atr"],
"bbc": ["ssd"],
"apple": ["dsk"],
"iigs": ["dsk"],
"amiga": ["adf"],
}
def disk_formats(platform: str) -> list[str]:
return _SS_FORMATS.get(platform, [])
def check_modes(platform: str, convs: list[Conversion]) -> None:
"""Raise ValueError if the queue's modes can't share one slideshow viewer."""
if platform == "c64":
slideshow_flavor(convs) # raises on an illegal mix
elif platform == "c128":
bad = [c.meta.get("vdc_mode") for c in convs
if c.meta.get("vdc_mode") != "hicolor"]
if bad:
raise ValueError(
"C128 slideshows support the 640x200 hicolor/mono VDC mode only; "
"the 80x100 'color' mode is not yet supported")
elif platform == "atari":
viewers = {c.viewer for c in convs}
if not viewers <= {"gr15", "gr9", "gr8", "gr15dli"} or len(viewers) != 1:
raise ValueError(
"Atari slideshows support gr15 / gr9 / gr8 / gr15dli -- all slides "
"must use the same one of these modes")
elif platform == "bbc":
modes = {c.meta.get("bbc_mode") for c in convs}
if len(modes) != 1:
raise ValueError(
"BBC slideshows must use a single screen mode -- all slides must "
"be the same BBC mode")
elif platform == "apple":
viewers = {c.viewer for c in convs}
if viewers != {"hgr"}:
raise ValueError(
"Apple slideshows currently support HGR (hgr_color/hgr_mono) only")
if len(convs) > _APPLE_BUFFER_PAGES // 0x20:
raise ValueError(
f"Apple HGR slideshows hold at most {_APPLE_BUFFER_PAGES // 0x20} "
"images (they load into RAM at once)")
elif platform == "iigs":
viewers = {c.viewer for c in convs}
if viewers != {"iigs"}:
raise ValueError("IIgs slideshows support the SHR mode only")
if len(convs) > _IIGS_MAX_IMAGES:
raise ValueError(
f"IIgs SHR slideshows hold at most {_IIGS_MAX_IMAGES} images "
"(32K each on a 140K 5.25\" disk)")
elif platform == "amiga":
# every Amiga image is a self-contained copper+bitplanes blob, so any
# lowres/HAM mix is fine; only the 880K floppy bounds the count.
b = budget("amiga", "adf", [image_nbytes(c) for c in convs], 0)
if not b.fits:
raise ValueError(f"Amiga slideshow does not fit an .adf: {b.reason}")
def viewer_length(show: Slideshow, convs: list[Conversion],
video: str = "pal") -> int:
"""Assembled length of the slideshow viewer PRG (for the storage budget)."""
if show.platform == "c64":
from .viewer.assemble import build_slideshow_prg
return len(build_slideshow_prg(
[_mode_byte(c) for c in convs], advance=show.advance,
seconds=show.seconds, loop=show.loop, video=video,
flavor=slideshow_flavor(convs)))
if show.platform == "c128":
from .c128.viewer.assemble import build_slideshow_prg
return len(build_slideshow_prg(
[c.meta.get("fgbg", 0) for c in convs], advance=show.advance,
seconds=show.seconds, loop=show.loop, video=video))
if show.platform == "atari":
# the boot viewer occupies the fixed boot-sector reservation regardless
# of its exact length, which is what budget() charges for it.
return _ATR_BOOT_SECTORS * _ATR_BLOCK
if show.platform == "bbc":
from .bbc.viewer.assemble import build_slideshow_viewer
m0 = convs[0].meta
return len(build_slideshow_viewer(
m0["bbc_mode"], m0["ncol"], m0["base"],
[c.meta["physicals"] for c in convs], advance=show.advance,
seconds=show.seconds, loop=show.loop, video=video))
if show.platform == "apple":
return 0 # boot loader is at $0800, outside the image buffer
if show.platform == "iigs":
return 0 # budget charges a fixed boot+stage2 sector reservation
if show.platform == "amiga":
return 0 # budget charges the fixed 2-sector boot block
raise NotImplementedError(f"viewer_length missing for {show.platform}")
def _check_budget(show: Slideshow, data_lens: list[int], viewer_len: int) -> None:
b = budget(show.platform, show.disk_format, data_lens, viewer_len)
if not b.fits:
raise diskimage.DiskError(
f"slideshow does not fit a {show.disk_format}: {b.reason}; "
f"use a larger disk format or remove images")
def build_disk(show: Slideshow, output_path: str, *, intensive: bool = False,
disk_name: str | None = None, video: str = "pal",
convs: list[Conversion] | None = None) -> str:
"""Convert every slide, assemble the slideshow viewer, and write the disk.
Pass ``convs`` to reuse already-computed conversions (the GUI caches them);
otherwise each item is converted here. Raises diskimage.DiskError if the
chosen format cannot hold the show (the budget is checked up front so the
message names the offending limit).
"""
if not supports_slideshow(show.platform):
raise NotImplementedError(
f"slideshow is not implemented for platform {show.platform}")
if not show.items:
raise ValueError("slideshow has no images")
if show.advance not in ADVANCE_MODES:
raise ValueError(f"advance must be one of {ADVANCE_MODES}")
if convs is None:
convs = [convert_item(show, it, intensive) for it in show.items]
if show.platform == "c64":
return _build_disk_c64(show, output_path, convs, disk_name, video)
if show.platform == "c128":
return _build_disk_c128(show, output_path, convs, disk_name, video)
if show.platform == "atari":
return _build_disk_atari(show, output_path, convs, disk_name, video)
if show.platform == "bbc":
return _build_disk_bbc(show, output_path, convs, disk_name, video)
if show.platform == "apple":
return _build_disk_apple(show, output_path, convs, disk_name, video)
if show.platform == "iigs":
return _build_disk_iigs(show, output_path, convs, disk_name, video)
if show.platform == "amiga":
return _build_disk_amiga(show, output_path, convs, disk_name, video)
raise NotImplementedError(f"slideshow build missing for {show.platform}")
def _build_disk_c64(show, output_path, convs, disk_name, video) -> str:
from .viewer.assemble import build_data_prg, build_slideshow_prg
flavor = slideshow_flavor(convs) # validates modes are uniform-enough
modes = [_mode_byte(c) for c in convs]
viewer = build_slideshow_prg(modes, advance=show.advance, seconds=show.seconds,
loop=show.loop, video=video, flavor=flavor)
_check_budget(show, [len(c.data) for c in convs], len(viewer))
stem = os.path.splitext(os.path.basename(output_path))[0]
name = diskimage.petscii_name(disk_name or stem or "slideshow")
files = [(name, viewer)]
for i, c in enumerate(convs):
files.append((data_filename(i), build_data_prg(c.data, c.data_addr)))
fmt = diskimage.fmt_from_path(output_path, show.disk_format)
return diskimage.build_disk(output_path, fmt, name, "01", files)
# C128 slideshow data files load into RAM bank 0 at $4000 (the viewer copies them
# to VDC RAM); only the 640x200 hicolor/mono VDC mode is supported.
_C128_DATA_ADDR = 0x4000
def _build_disk_c128(show, output_path, convs, disk_name, video) -> str:
from .c128.viewer.assemble import build_slideshow_prg
from .viewer.assemble import build_data_prg
bad = [c.meta.get("vdc_mode") for c in convs if c.meta.get("vdc_mode") != "hicolor"]
if bad:
raise ValueError(
"C128 slideshows support the 640x200 hicolor/mono VDC mode only "
f"(got {bad[0]!r}); the 80x100 'color' mode is not yet supported")
fgbg = [c.meta.get("fgbg", 0) for c in convs]
viewer = build_slideshow_prg(fgbg, advance=show.advance, seconds=show.seconds,
loop=show.loop, video=video)
_check_budget(show, [len(c.data) for c in convs], len(viewer))
files = [("pic", viewer)] # boots via RUN"PIC"
for i, c in enumerate(convs):
files.append((data_filename(i), build_data_prg(c.data, _C128_DATA_ADDR)))
stem = os.path.splitext(os.path.basename(output_path))[0]
name = diskimage.petscii_name(disk_name or stem or "slideshow")
return diskimage.build_disk(output_path, "d64", name, "cv", files)
def _build_disk_atari(show, output_path, convs, disk_name, video) -> str:
from .atari import atr
from .atari.viewer.assemble import build_slideshow_stub
check_modes("atari", convs) # uniform gr15/gr9/gr8 (raises otherwise)
spi = max(item_blocks("atari", "atr", len(c.data)) for c in convs)
base = _ATR_BOOT_SECTORS + 1
stub = build_slideshow_stub(convs[0].viewer, len(convs), base, spi,
advance=show.advance, seconds=show.seconds,
loop=show.loop, video=video)
_check_budget(show, [len(c.data) for c in convs], len(stub))
if not output_path.lower().endswith(".atr"):
output_path += ".atr"
return atr.write_slideshow_atr(output_path, stub, [c.data for c in convs],
boot_sectors=_ATR_BOOT_SECTORS, spi=spi)
def _build_disk_bbc(show, output_path, convs, disk_name, video) -> str:
from .bbc import ssd
from .bbc.viewer.assemble import LOAD_ADDR, build_slideshow_viewer
check_modes("bbc", convs) # uniform BBC mode (raises otherwise)
m0 = convs[0].meta
viewer = build_slideshow_viewer(
m0["bbc_mode"], m0["ncol"], m0["base"],
[c.meta["physicals"] for c in convs], advance=show.advance,
seconds=show.seconds, loop=show.loop, video=video)
_check_budget(show, [len(c.data) for c in convs], len(viewer))
if not output_path.lower().endswith(".ssd"):
output_path += ".ssd"
# !BOOT autostarts on SHIFT+BREAK; PIC is the same loader *RUN from a command.
files = [("!BOOT", LOAD_ADDR, LOAD_ADDR, viewer),
("PIC", LOAD_ADDR, LOAD_ADDR, viewer)]
for i, c in enumerate(convs):
files.append((data_filename(i), c.meta["base"], c.meta["base"], c.data))
title = diskimage.petscii_name(disk_name or "8bitlenser", 12).upper() or "8BITLENSER"
ssd.write_ssd(output_path, files, title=title, boot_option=2)
return output_path
def _build_disk_apple(show, output_path, convs, disk_name, video) -> str:
from .apple import dsk
from .apple.viewer.assemble import build_slideshow_stub
check_modes("apple", convs) # uniform HGR (raises otherwise)
_check_budget(show, [len(c.data) for c in convs], 0)
boot = build_slideshow_stub(len(convs), advance=show.advance,
seconds=show.seconds, loop=show.loop)
payload = b"".join(c.data for c in convs) # images read contiguously to $4000+
if not output_path.lower().endswith((".dsk", ".do")):
output_path += ".dsk"
return dsk.write_dsk(output_path, dsk.build_boot_dsk(boot, payload))
def _build_disk_iigs(show, output_path, convs, disk_name, video) -> str:
from .apple import dsk
from .iigs.viewer.assemble import build_slideshow
check_modes("iigs", convs) # uniform SHR, <=4 images
_check_budget(show, [len(c.data) for c in convs], 0)
boot, stage2, _ = build_slideshow(len(convs), advance=show.advance,
seconds=show.seconds, loop=show.loop, video=video)
# payload = stage2 (padded to whole sectors) then the SHR images, all read
# contiguously by the boot: stage2 -> $0900, each image -> bank-0 $2000.
payload = bytes(stage2) + bytes((-len(stage2)) % 256)
payload += b"".join(c.data for c in convs)
if not output_path.lower().endswith((".dsk", ".do")):
output_path += ".dsk"
return dsk.write_dsk(output_path, dsk.build_boot_dsk(boot, payload))
def _build_disk_amiga(show, output_path, convs, disk_name, video) -> str:
from .amiga import viewer
check_modes("amiga", convs) # any blobs; bounded by the floppy
if not output_path.lower().endswith(".adf"):
output_path += ".adf"
adf = viewer.build_slideshow_adf([c.data for c in convs], advance=show.advance,
seconds=show.seconds, loop=show.loop, video=video)
return viewer.write_adf(adf, output_path)