1122 lines
48 KiB
Python
1122 lines
48 KiB
Python
"""PyQt5 GUI: open an image, tune the conversion with a live C64 preview, export a disk image."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
import sys
|
|
import traceback
|
|
|
|
import numpy as np
|
|
from PyQt5 import QtCore, QtGui, QtWidgets
|
|
|
|
from . import gallery, imageprep, platforms, slideshow
|
|
from .convert import render_preview
|
|
from .diskimage import have_c1541
|
|
from .palette import COLOR_NAMES
|
|
from .viewer.assemble import have_xa
|
|
|
|
DITHER_CHOICES = ["bayer", "bluenoise", "yliluoma", "floyd", "atkinson",
|
|
"stucki", "jarvis", "sierra", "sierra_lite", "burkes",
|
|
"riemersma", "ostromoukhov", "none"]
|
|
ASPECT_CHOICES = ["fit", "fill", "stretch"]
|
|
BASE_CHOICES = ["grayscale", *COLOR_NAMES] # mono base / Atari hue
|
|
|
|
# Full machine names shown in the platform selector (value stays the short code).
|
|
PLATFORM_NAMES = {
|
|
"c64": "Commodore 64", "atari": "Atari 800/XL/XE", "apple": "Apple II",
|
|
"ti99": "Texas Instruments TI-99/4A", "coco": "Tandy Color Computer 2",
|
|
"bbc": "BBC Micro", "coleco": "ColecoVision", "a2600": "Atari 2600",
|
|
"intv": "Mattel Intellivision", "vic20": "Commodore VIC-20",
|
|
"spectrum": "Sinclair ZX Spectrum", "a5200": "Atari 5200",
|
|
"a7800": "Atari 7800", "c128": "Commodore 128", "c16": "Commodore 16",
|
|
"plus4": "Commodore Plus/4", "cpc": "Amstrad CPC",
|
|
"coco3": "Tandy Color Computer 3", "nes": "Nintendo Entertainment System",
|
|
"iigs": "Apple IIGS", "pet2001": "Commodore PET 2001",
|
|
"pet4032": "Commodore PET 4032", "pet8032": "Commodore PET 8032",
|
|
"superpet": "Commodore SuperPET", "sms": "Sega Master System",
|
|
"amiga": "Commodore Amiga", "ansi": "ANSI / BBS art (CP437)",
|
|
}
|
|
|
|
|
|
def numpy_to_pixmap(rgb: np.ndarray) -> QtGui.QPixmap:
|
|
rgb = np.ascontiguousarray(rgb)
|
|
h, w, _ = rgb.shape
|
|
img = QtGui.QImage(rgb.data, w, h, 3 * w, QtGui.QImage.Format_RGB888)
|
|
return QtGui.QPixmap.fromImage(img.copy())
|
|
|
|
|
|
# Small, original brand-evocative chips for the platform selector: a coloured
|
|
# rounded square + a short monogram in a colour associated with the maker. These
|
|
# are drawn from scratch to *evoke* each brand, not reproductions of any logo.
|
|
# (bg, fg, label) keyed by platform code; grouped by manufacturer.
|
|
_BRAND_ICONS = {
|
|
# Commodore (indigo "C=")
|
|
**{c: ("#3f51b5", "#ffffff", "C=") for c in
|
|
("c64", "c128", "c16", "plus4", "vic20",
|
|
"pet2001", "pet4032", "pet8032", "superpet")},
|
|
"amiga": ("#111111", "#e53935", "A"), # Commodore Amiga (red on black)
|
|
# Atari (red)
|
|
**{c: ("#c62828", "#ffffff", "A") for c in ("atari", "a2600", "a5200", "a7800")},
|
|
# Apple (green era)
|
|
"apple": ("#7ab648", "#ffffff", "II"),
|
|
"iigs": ("#7ab648", "#ffffff", "GS"),
|
|
# everyone else
|
|
"ti99": ("#cc0000", "#ffffff", "TI"), # Texas Instruments
|
|
"coco": ("#c8102e", "#ffffff", "TRS"), # Tandy / Radio Shack
|
|
"coco3": ("#c8102e", "#ffffff", "TRS"),
|
|
"bbc": ("#111111", "#ffffff", "BBC"), # Acorn / BBC
|
|
"coleco": ("#e53935", "#ffffff", "CV"), # ColecoVision
|
|
"intv": ("#1565c0", "#ffffff", "M"), # Mattel
|
|
"spectrum": ("#111111", "#ffffff", "ZX"), # Sinclair
|
|
"nes": ("#e60012", "#ffffff", "N"), # Nintendo
|
|
"sms": ("#0060a8", "#ffffff", "S"), # Sega
|
|
"cpc": ("#1b3a6b", "#ffffff", "CPC"), # Amstrad
|
|
"ansi": ("#0a0a0a", "#33ff33", "▒"), # ANSI/BBS terminal (CP437 shade)
|
|
}
|
|
|
|
|
|
def _brand_icon(code: str, px: int = 18) -> QtGui.QIcon:
|
|
"""A tiny brand-evocative chip icon for one platform (empty icon if unknown)."""
|
|
spec = _BRAND_ICONS.get(code)
|
|
if spec is None:
|
|
return QtGui.QIcon()
|
|
bg, fg, label = spec
|
|
dpr = 2 # render at 2x for a crisp downscale
|
|
size = px * dpr
|
|
pm = QtGui.QPixmap(size, size)
|
|
pm.fill(QtCore.Qt.transparent)
|
|
p = QtGui.QPainter(pm)
|
|
p.setRenderHint(QtGui.QPainter.Antialiasing, True)
|
|
p.setRenderHint(QtGui.QPainter.TextAntialiasing, True)
|
|
rect = QtCore.QRectF(dpr, dpr, size - 2 * dpr, size - 2 * dpr)
|
|
p.setPen(QtCore.Qt.NoPen)
|
|
p.setBrush(QtGui.QColor(bg))
|
|
p.drawRoundedRect(rect, 4 * dpr, 4 * dpr)
|
|
# shrink the (bold) monogram until it fits the chip width
|
|
font = QtGui.QFont()
|
|
font.setBold(True)
|
|
fs = int(size * 0.64)
|
|
while fs > 4:
|
|
font.setPixelSize(fs)
|
|
if QtGui.QFontMetrics(font).horizontalAdvance(label) <= rect.width() * 0.82:
|
|
break
|
|
fs -= 1
|
|
p.setFont(font)
|
|
p.setPen(QtGui.QColor(fg))
|
|
p.drawText(rect, QtCore.Qt.AlignCenter, label)
|
|
p.end()
|
|
pm.setDevicePixelRatio(dpr)
|
|
return QtGui.QIcon(pm)
|
|
|
|
|
|
def _home(name: str) -> str:
|
|
"""A suggested save path: the user's home directory + ``name`` (so every Save
|
|
dialog opens in $HOME by default)."""
|
|
return os.path.join(os.path.expanduser("~"), name)
|
|
|
|
|
|
def _load_bold_font(size: int):
|
|
"""A bold TrueType font at ``size`` px (for the contact-sheet title), falling
|
|
back to PIL's built-in font if no system bold font is available."""
|
|
from PIL import ImageFont
|
|
for path in ("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
|
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
|
"DejaVuSans-Bold.ttf"):
|
|
try:
|
|
return ImageFont.truetype(path, size)
|
|
except OSError:
|
|
continue
|
|
try:
|
|
return ImageFont.load_default(size) # Pillow >= 10.1
|
|
except Exception:
|
|
return ImageFont.load_default()
|
|
|
|
|
|
class ConvertWorker(QtCore.QThread):
|
|
"""Runs one conversion off the UI thread."""
|
|
done = QtCore.pyqtSignal(object) # Conversion
|
|
failed = QtCore.pyqtSignal(str)
|
|
|
|
def __init__(self, path, params):
|
|
super().__init__()
|
|
self.path = path
|
|
self.params = params
|
|
|
|
def run(self):
|
|
try:
|
|
p = self.params
|
|
prep = imageprep.PrepOptions(
|
|
aspect=p["aspect"], brightness=p["brightness"],
|
|
contrast=p["contrast"], saturation=p["saturation"], gamma=p["gamma"],
|
|
tint=p["tint"], red=p["red"], green=p["green"], blue=p["blue"],
|
|
)
|
|
conv = platforms.convert(
|
|
p["platform"], self.path, p["mode"], p["palette"], p["dither"],
|
|
p["intensive"], prep, p["base_name"],
|
|
)
|
|
self.done.emit(conv)
|
|
except Exception:
|
|
self.failed.emit(traceback.format_exc())
|
|
|
|
|
|
class GalleryWorker(QtCore.QThread):
|
|
"""Renders every Mode x Palette x Dither variation across CPU cores."""
|
|
result = QtCore.pyqtSignal(int, str, str, str, float, object)
|
|
progress = QtCore.pyqtSignal(int, int)
|
|
finished_all = QtCore.pyqtSignal()
|
|
|
|
def __init__(self, path, prep_kwargs, platform, base_name):
|
|
super().__init__()
|
|
self.path = path
|
|
self.prep_kwargs = prep_kwargs
|
|
self.platform = platform
|
|
self.base_name = base_name
|
|
self._cancel = False
|
|
|
|
def cancel(self):
|
|
self._cancel = True
|
|
|
|
def run(self):
|
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
combos = gallery.combos(self.platform)
|
|
args = [(self.path, self.platform, m, p, d, self.prep_kwargs, self.base_name)
|
|
for (m, p, d) in combos]
|
|
total = len(combos)
|
|
done = 0
|
|
try:
|
|
with ProcessPoolExecutor() as ex:
|
|
futs = {ex.submit(gallery.render_variation, a): i
|
|
for i, a in enumerate(args)}
|
|
for fut in as_completed(futs):
|
|
if self._cancel:
|
|
ex.shutdown(wait=False, cancel_futures=True)
|
|
break
|
|
i = futs[fut]
|
|
try:
|
|
m, p, d, err, rgb = fut.result()
|
|
except Exception:
|
|
continue
|
|
done += 1
|
|
self.result.emit(i, m, p, d, err, rgb)
|
|
self.progress.emit(done, total)
|
|
finally:
|
|
self.finished_all.emit()
|
|
|
|
|
|
class VariationsDialog(QtWidgets.QDialog):
|
|
"""Contact sheet of every Mode x Palette x Dither combination to pick from."""
|
|
|
|
def __init__(self, path, prep_kwargs, platform="c64", base_name="grayscale",
|
|
parent=None, cached=None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("Explore variations -- pick the best looking one")
|
|
self.resize(940, 680)
|
|
self.path = path
|
|
self.choice = None
|
|
self.cells = {}
|
|
self.best = (float("inf"), None)
|
|
self.collected = {} # index -> (m, p, d, err, rgb)
|
|
self.worker = None
|
|
self._combos = gallery.combos(platform)
|
|
|
|
outer = QtWidgets.QVBoxLayout(self)
|
|
self.info = QtWidgets.QLabel("Rendering variations... click any thumbnail to choose it")
|
|
outer.addWidget(self.info)
|
|
|
|
scroll = QtWidgets.QScrollArea()
|
|
scroll.setWidgetResizable(True)
|
|
inner = QtWidgets.QWidget()
|
|
self.grid = QtWidgets.QGridLayout(inner)
|
|
scroll.setWidget(inner)
|
|
outer.addWidget(scroll, 1)
|
|
|
|
# floating 2x magnifier shown while hovering a thumbnail (closer inspection)
|
|
self._zoom = QtWidgets.QLabel(self)
|
|
self._zoom.setWindowFlags(QtCore.Qt.ToolTip)
|
|
self._zoom.setAlignment(QtCore.Qt.AlignCenter)
|
|
self._zoom.setStyleSheet("border: 1px solid #888; background: #111;")
|
|
self._zoom.hide()
|
|
|
|
cols = 4
|
|
for i, (m, p, d) in enumerate(self._combos):
|
|
btn = QtWidgets.QToolButton()
|
|
btn.setToolButtonStyle(QtCore.Qt.ToolButtonTextUnderIcon)
|
|
btn.setIconSize(QtCore.QSize(200, 125))
|
|
btn.setText(f"{m} - {p} - {d}\n(rendering...)")
|
|
btn.setEnabled(False)
|
|
btn.clicked.connect(lambda _checked, idx=i: self._choose(idx))
|
|
btn._zoom_pm = None # 2x pixmap, set once rendered
|
|
btn.installEventFilter(self) # hover -> show/hide magnifier
|
|
self.cells[i] = btn
|
|
self.grid.addWidget(btn, i // cols, i % cols)
|
|
|
|
btns = QtWidgets.QHBoxLayout()
|
|
self.sheet_btn = QtWidgets.QPushButton("Contact Sheet...")
|
|
self.sheet_btn.setToolTip("Save one PNG with every variation at full size, "
|
|
"for side-by-side comparison.")
|
|
self.sheet_btn.clicked.connect(self._save_contact_sheet)
|
|
self.sheet_btn.setEnabled(False)
|
|
btns.addWidget(self.sheet_btn)
|
|
btns.addStretch(1)
|
|
cancel = QtWidgets.QPushButton("Cancel")
|
|
cancel.clicked.connect(self.reject)
|
|
btns.addWidget(cancel)
|
|
outer.addLayout(btns)
|
|
|
|
if cached and len(cached) == len(self._combos):
|
|
for i, vals in cached.items():
|
|
self._populate(i, *vals)
|
|
self._on_done() # show "best" marker + final message
|
|
else:
|
|
self.worker = GalleryWorker(path, prep_kwargs, platform, base_name)
|
|
self.worker.result.connect(self._on_result)
|
|
self.worker.progress.connect(self._on_progress)
|
|
self.worker.finished_all.connect(self._on_done)
|
|
self.worker.start()
|
|
|
|
def _populate(self, i, m, p, d, err, rgb):
|
|
btn = self.cells[i]
|
|
pm = numpy_to_pixmap(rgb).scaled(200, 125, QtCore.Qt.KeepAspectRatio,
|
|
QtCore.Qt.SmoothTransformation)
|
|
btn.setIcon(QtGui.QIcon(pm))
|
|
# 2x version for the hover magnifier (twice the thumbnail's box)
|
|
btn._zoom_pm = numpy_to_pixmap(rgb).scaled(
|
|
400, 250, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
|
|
btn.setText(f"{m} - {p} - {d}\ndE {err:.1f}")
|
|
btn.setEnabled(True)
|
|
btn.setProperty("combo", (m, p, d))
|
|
self.collected[i] = (m, p, d, err, rgb)
|
|
if err < self.best[0]:
|
|
self.best = (err, i)
|
|
|
|
def _on_result(self, i, m, p, d, err, rgb):
|
|
self._populate(i, m, p, d, err, rgb)
|
|
|
|
def _on_progress(self, done, total):
|
|
self.info.setText(f"Rendering variations... {done}/{total} "
|
|
f"-- click any thumbnail to choose it")
|
|
|
|
def _on_done(self):
|
|
msg = "Click the variation you like best."
|
|
if self.best[1] is not None:
|
|
m, p, d = self.cells[self.best[1]].property("combo")
|
|
self.cells[self.best[1]].setText(
|
|
self.cells[self.best[1]].text() + " *best*")
|
|
msg += f" (lowest error: {m} / {p} / {d})"
|
|
self.info.setText(msg)
|
|
self.sheet_btn.setEnabled(bool(self.collected))
|
|
|
|
def _save_contact_sheet(self):
|
|
"""Compose every collected variation (at full preview size) into one PNG
|
|
grid -- same order as the on-screen sheet -- for side-by-side comparison."""
|
|
if not self.collected:
|
|
return
|
|
stem = os.path.splitext(os.path.basename(self.path or "image"))[0]
|
|
path, _ = QtWidgets.QFileDialog.getSaveFileName(
|
|
self, "Save contact sheet", _home(f"8bitlenser_{stem}.png"),
|
|
"PNG image (*.png)")
|
|
if not path:
|
|
return
|
|
if not path.lower().endswith(".png"):
|
|
path += ".png"
|
|
try:
|
|
from PIL import Image, ImageDraw, ImageFont
|
|
titlefont = _load_bold_font(26)
|
|
items = [self.collected[i] for i in sorted(self.collected)]
|
|
best_i = self.best[1]
|
|
best_pos = sorted(self.collected).index(best_i) if best_i is not None else -1
|
|
cw = max(rgb.shape[1] for *_, rgb in items)
|
|
ch = max(rgb.shape[0] for *_, rgb in items)
|
|
pad, lblh, cols, titleh = 10, 18, 4, 44
|
|
cellw, cellh = cw + 2 * pad, ch + lblh + 2 * pad
|
|
rows = (len(items) + cols - 1) // cols
|
|
sheet = Image.new("RGB", (cols * cellw, titleh + rows * cellh), (24, 24, 24))
|
|
draw = ImageDraw.Draw(sheet)
|
|
draw.text((pad, (titleh - 26) // 2), os.path.abspath(self.path or ""),
|
|
fill=(255, 255, 255), font=titlefont)
|
|
for pos, (m, p, d, err, rgb) in enumerate(items):
|
|
r, c = divmod(pos, cols)
|
|
x0, y0 = c * cellw + pad, titleh + r * cellh + pad
|
|
star = " *best*" if pos == best_pos else ""
|
|
draw.text((x0, y0), f"{m}/{p}/{d} dE {err:.1f}{star}",
|
|
fill=(255, 230, 120) if star else (220, 220, 220))
|
|
tile = Image.fromarray(np.ascontiguousarray(rgb), "RGB")
|
|
ox = x0 + (cw - rgb.shape[1]) // 2
|
|
oy = y0 + lblh + (ch - rgb.shape[0]) // 2
|
|
sheet.paste(tile, (ox, oy))
|
|
sheet.save(path)
|
|
self.info.setText(f"Saved contact sheet ({len(items)} variations) to {path}")
|
|
except Exception:
|
|
QtWidgets.QMessageBox.critical(self, "Contact sheet failed",
|
|
traceback.format_exc())
|
|
|
|
def eventFilter(self, obj, event):
|
|
"""Show a 2x magnifier when the pointer enters a rendered thumbnail."""
|
|
et = event.type()
|
|
if et == QtCore.QEvent.Enter and getattr(obj, "_zoom_pm", None) is not None:
|
|
self._show_zoom(obj)
|
|
elif et == QtCore.QEvent.Leave and obj is not self._zoom:
|
|
self._zoom.hide()
|
|
return super().eventFilter(obj, event)
|
|
|
|
def _show_zoom(self, btn):
|
|
"""Pop the magnified pixmap just above ``btn`` (below if there's no room),
|
|
clamped to the screen so it never lands under the pointer."""
|
|
pm = btn._zoom_pm
|
|
self._zoom.setPixmap(pm)
|
|
self._zoom.resize(pm.size())
|
|
screen = QtWidgets.QApplication.desktop().availableGeometry(btn)
|
|
cx = btn.mapToGlobal(btn.rect().center()).x()
|
|
x = max(screen.left(), min(cx - pm.width() // 2, screen.right() - pm.width()))
|
|
y = btn.mapToGlobal(btn.rect().topLeft()).y() - pm.height() - 6
|
|
if y < screen.top(): # no room above -> drop below
|
|
y = btn.mapToGlobal(btn.rect().bottomLeft()).y() + 6
|
|
self._zoom.move(x, y)
|
|
self._zoom.show()
|
|
self._zoom.raise_()
|
|
|
|
def _choose(self, i):
|
|
self.choice = self.cells[i].property("combo")
|
|
self.accept()
|
|
|
|
def reject(self):
|
|
if self.worker:
|
|
self.worker.cancel()
|
|
self.worker.wait(2000)
|
|
super().reject()
|
|
|
|
|
|
class SlideshowDialog(QtWidgets.QDialog):
|
|
"""Arrange a queue of slides, size them against a disk format (refusing to
|
|
overfill), set the advance behaviour, and export/run one drive image.
|
|
|
|
Operates on the parent's live ``slides`` list (reorder/remove happen in
|
|
place), where each entry is {"item": SlideItem, "conv": Conversion,
|
|
"thumb": rgb}.
|
|
"""
|
|
|
|
def __init__(self, slides, platform, parent=None):
|
|
super().__init__(parent)
|
|
self.setWindowTitle("Slideshow")
|
|
self.resize(580, 540)
|
|
self.slides = slides
|
|
self.platform = platform
|
|
self._build()
|
|
self._refresh()
|
|
|
|
def _build(self):
|
|
lay = QtWidgets.QVBoxLayout(self)
|
|
lay.addWidget(QtWidgets.QLabel(
|
|
f"Platform: {PLATFORM_NAMES.get(self.platform, self.platform)} "
|
|
"(one platform per slideshow)"))
|
|
|
|
self.listw = QtWidgets.QListWidget()
|
|
self.listw.setIconSize(QtCore.QSize(96, 60))
|
|
lay.addWidget(self.listw, 1)
|
|
|
|
rowbtns = QtWidgets.QHBoxLayout()
|
|
for text, fn in (("Move up", lambda: self._move(-1)),
|
|
("Move down", lambda: self._move(1)),
|
|
("Remove", self._remove)):
|
|
b = QtWidgets.QPushButton(text)
|
|
b.clicked.connect(fn)
|
|
rowbtns.addWidget(b)
|
|
lay.addLayout(rowbtns)
|
|
|
|
form = QtWidgets.QFormLayout()
|
|
self.advance_cb = QtWidgets.QComboBox()
|
|
self.advance_cb.addItems(list(slideshow.ADVANCE_MODES))
|
|
self.advance_cb.setCurrentText("both")
|
|
self.advance_cb.setToolTip("key = wait for a keypress; seconds = auto after "
|
|
"N s; both = either (keys still work).")
|
|
self.seconds_sb = QtWidgets.QSpinBox()
|
|
self.seconds_sb.setRange(1, 3600)
|
|
self.seconds_sb.setValue(10)
|
|
self.seconds_sb.setSuffix(" s")
|
|
adv_row = QtWidgets.QHBoxLayout()
|
|
adv_row.addWidget(self.advance_cb)
|
|
adv_row.addWidget(self.seconds_sb)
|
|
self.format_cb = QtWidgets.QComboBox()
|
|
self.format_cb.addItems(slideshow.disk_formats(self.platform))
|
|
self.loop_cb = QtWidgets.QCheckBox("Loop back to the first image")
|
|
self.loop_cb.setChecked(True)
|
|
form.addRow("Advance", adv_row)
|
|
form.addRow("Disk format", self.format_cb)
|
|
form.addRow("", self.loop_cb)
|
|
lay.addLayout(form)
|
|
self.advance_cb.currentTextChanged.connect(self._adv_changed)
|
|
self.format_cb.currentTextChanged.connect(self._refresh)
|
|
self.loop_cb.stateChanged.connect(self._refresh)
|
|
# set the spinbox enable directly (meter/_refresh aren't built yet)
|
|
self.seconds_sb.setEnabled(self.advance_cb.currentText() in ("seconds", "both"))
|
|
|
|
self.meter = QtWidgets.QLabel("")
|
|
lay.addWidget(self.meter)
|
|
|
|
btns = QtWidgets.QHBoxLayout()
|
|
self.run_btn = QtWidgets.QPushButton("Run in emulator")
|
|
self.run_btn.clicked.connect(self._run)
|
|
self.export_btn = QtWidgets.QPushButton("Export slideshow disk...")
|
|
self.export_btn.clicked.connect(self._export)
|
|
close = QtWidgets.QPushButton("Close")
|
|
close.clicked.connect(self.accept)
|
|
btns.addWidget(self.run_btn)
|
|
btns.addStretch(1)
|
|
btns.addWidget(self.export_btn)
|
|
btns.addWidget(close)
|
|
lay.addLayout(btns)
|
|
|
|
def _adv_changed(self, text):
|
|
self.seconds_sb.setEnabled(text in ("seconds", "both"))
|
|
self._refresh()
|
|
|
|
def _selected(self):
|
|
row = self.listw.currentRow()
|
|
return row if 0 <= row < len(self.slides) else -1
|
|
|
|
def _move(self, delta):
|
|
i = self._selected()
|
|
j = i + delta
|
|
if i < 0 or not (0 <= j < len(self.slides)):
|
|
return
|
|
self.slides[i], self.slides[j] = self.slides[j], self.slides[i]
|
|
self._refresh()
|
|
self.listw.setCurrentRow(j)
|
|
|
|
def _remove(self):
|
|
i = self._selected()
|
|
if i >= 0:
|
|
del self.slides[i]
|
|
self._refresh()
|
|
|
|
def _viewer_len(self):
|
|
if not self.slides:
|
|
return 0
|
|
try:
|
|
return slideshow.viewer_length(self._show(),
|
|
[s["conv"] for s in self.slides],
|
|
self._video())
|
|
except Exception:
|
|
return 320 # xa unavailable; a safe over-estimate for sizing
|
|
|
|
def _show(self):
|
|
return slideshow.Slideshow(
|
|
platform=self.platform, disk_format=self.format_cb.currentText(),
|
|
advance=self.advance_cb.currentText(), seconds=self.seconds_sb.value(),
|
|
loop=self.loop_cb.isChecked(), items=[s["item"] for s in self.slides])
|
|
|
|
def _video(self):
|
|
p = self.parent()
|
|
return p.video_cb.currentText() if p is not None else "pal"
|
|
|
|
def _refresh(self):
|
|
fmt = self.format_cb.currentText()
|
|
self.listw.clear()
|
|
for i, s in enumerate(self.slides):
|
|
it, conv = s["item"], s["conv"]
|
|
blk = slideshow.item_blocks(self.platform, fmt, len(conv.data))
|
|
item = QtWidgets.QListWidgetItem(
|
|
f"{i + 1:02d}. {it.mode} / {it.dither} {blk} blocks "
|
|
f"{os.path.basename(it.source_path)}")
|
|
if s.get("thumb") is not None:
|
|
item.setIcon(QtGui.QIcon(numpy_to_pixmap(s["thumb"])))
|
|
self.listw.addItem(item)
|
|
|
|
if not self.slides:
|
|
self.meter.setText("No images yet -- add them from the main window.")
|
|
self.meter.setStyleSheet("")
|
|
self.export_btn.setEnabled(False)
|
|
self.run_btn.setEnabled(False)
|
|
return
|
|
try:
|
|
slideshow.check_modes(self.platform, [s["conv"] for s in self.slides])
|
|
except ValueError as e:
|
|
self.meter.setText(str(e))
|
|
self.meter.setStyleSheet("font-weight:bold;color:#c02020;")
|
|
self.export_btn.setEnabled(False)
|
|
self.run_btn.setEnabled(False)
|
|
return
|
|
b = slideshow.budget(self.platform, fmt,
|
|
[slideshow.image_nbytes(s["conv"]) for s in self.slides],
|
|
self._viewer_len())
|
|
verdict = "fits" if b.fits else f"OVER -- {b.reason}"
|
|
self.meter.setText(
|
|
f"{len(self.slides)} images {b.used_blocks}/{b.total_blocks} blocks "
|
|
f"{b.files}/{b.file_cap} files {verdict}")
|
|
self.meter.setStyleSheet(
|
|
"font-weight:bold;color:%s;" % ("#2a8a2a" if b.fits else "#c02020"))
|
|
self.export_btn.setEnabled(b.fits)
|
|
self.run_btn.setEnabled(b.fits and platforms.has_emulator(self.platform))
|
|
|
|
def _build_disk_to(self, path):
|
|
show = self._show()
|
|
return slideshow.build_disk(show, path, convs=[s["conv"] for s in self.slides],
|
|
video=self._video())
|
|
|
|
def _export(self):
|
|
fmt = self.format_cb.currentText()
|
|
path, _ = QtWidgets.QFileDialog.getSaveFileName(
|
|
self, "Export slideshow disk", _home(f"slideshow.{fmt}"),
|
|
f"Disk image (*.{fmt})")
|
|
if not path:
|
|
return
|
|
try:
|
|
out = self._build_disk_to(path)
|
|
QtWidgets.QMessageBox.information(
|
|
self, "Exported",
|
|
f"Wrote {out}\n\n{len(self.slides)} images.\n"
|
|
'On the C64 / emulator:\n LOAD"*",8,1 then RUN')
|
|
except Exception:
|
|
QtWidgets.QMessageBox.critical(self, "Export failed", traceback.format_exc())
|
|
|
|
def _run(self):
|
|
from . import mame
|
|
p = self.parent()
|
|
try:
|
|
import tempfile
|
|
fmt = self.format_cb.currentText()
|
|
fd, path = tempfile.mkstemp(suffix=f".{fmt}", prefix="slideshow_")
|
|
os.close(fd)
|
|
self._build_disk_to(path)
|
|
if p is not None:
|
|
p._temp_disks.append(path)
|
|
mame.kill(p._emu_proc)
|
|
p._emu_proc = platforms.run_emulator(
|
|
self.platform, path, self.slides[0]["conv"].mode, self._video())
|
|
self.parent().status.showMessage("Launched slideshow in emulator.")
|
|
except Exception:
|
|
QtWidgets.QMessageBox.critical(self, "Run failed", traceback.format_exc())
|
|
|
|
|
|
class MainWindow(QtWidgets.QMainWindow):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.setWindowTitle("8 Bit Lenser -- modern images to retro disks")
|
|
self.resize(980, 620)
|
|
|
|
self.source_path = None
|
|
self.last_conv = None
|
|
self.worker = None
|
|
self.pending = False
|
|
self._gallery_key = None # cache key for Explore variations
|
|
self._gallery_results = None # cached {index: (m, p, d, err, rgb)}
|
|
self._slides = [] # slideshow queue (see SlideshowDialog)
|
|
self._slideshow_platform = None # locked to the first added slide
|
|
|
|
self._build_ui()
|
|
self.setAcceptDrops(True) # allow dragging an image onto the window
|
|
self.src_label["label"].setText("(no image)\n\nclick “Open image…” "
|
|
"or drag an image here")
|
|
self._check_tools()
|
|
|
|
# ---- UI construction ----
|
|
def _build_ui(self):
|
|
central = QtWidgets.QWidget()
|
|
self.setCentralWidget(central)
|
|
root = QtWidgets.QHBoxLayout(central)
|
|
|
|
# previews
|
|
previews = QtWidgets.QVBoxLayout()
|
|
self.src_label = self._make_preview("Original")
|
|
self.c64_label = self._make_preview("Preview")
|
|
previews.addWidget(self.src_label["box"])
|
|
previews.addWidget(self.c64_label["box"])
|
|
root.addLayout(previews, 3)
|
|
|
|
# controls
|
|
panel = QtWidgets.QVBoxLayout()
|
|
root.addLayout(panel, 1)
|
|
|
|
open_btn = QtWidgets.QPushButton("Open image...")
|
|
open_btn.clicked.connect(self.open_image)
|
|
panel.addWidget(open_btn)
|
|
|
|
self.explore_btn = QtWidgets.QPushButton("Explore variations...")
|
|
self.explore_btn.setToolTip(
|
|
"Render every Mode x Palette x Dither combination, pick the best,\n"
|
|
"then fine-tune brightness/contrast/etc.")
|
|
self.explore_btn.clicked.connect(self.explore_variations)
|
|
self.explore_btn.setEnabled(False)
|
|
panel.addWidget(self.explore_btn)
|
|
|
|
form = QtWidgets.QFormLayout()
|
|
self.platform_cb = QtWidgets.QComboBox()
|
|
self.platform_cb.setIconSize(QtCore.QSize(18, 18))
|
|
# list alphabetically by the name shown (code kept as the item data),
|
|
# each prefixed by a small brand-evocative icon
|
|
for code in sorted(platforms.PLATFORMS,
|
|
key=lambda c: PLATFORM_NAMES.get(c, c).lower()):
|
|
self.platform_cb.addItem(_brand_icon(code),
|
|
PLATFORM_NAMES.get(code, code), code)
|
|
self.platform_cb.setCurrentIndex(self.platform_cb.findData("c64"))
|
|
self.mode_cb = self._combo(platforms.modes("c64"), "multicolor")
|
|
self.format_cb = self._combo(platforms.disk_formats("c64"), "d64")
|
|
self.palette_cb = self._combo(platforms.palettes("c64"), "colodore")
|
|
self.dither_cb = self._combo(DITHER_CHOICES, "atkinson")
|
|
self.base_cb = self._combo(BASE_CHOICES, "grayscale")
|
|
self.video_cb = self._combo(["pal", "ntsc"], "pal")
|
|
self.aspect_cb = self._combo(ASPECT_CHOICES, "fit")
|
|
self.layout_cb = self._combo(["unified", "separate"], "unified")
|
|
self.display_cb = self._combo(["key", "forever", "seconds"], "key")
|
|
self.seconds_sb = QtWidgets.QSpinBox()
|
|
self.seconds_sb.setRange(1, 3600)
|
|
self.seconds_sb.setValue(10)
|
|
self.seconds_sb.setSuffix(" s")
|
|
self.seconds_sb.setEnabled(False)
|
|
self.display_cb.currentTextChanged.connect(
|
|
lambda t: self.seconds_sb.setEnabled(t == "seconds"))
|
|
disp_row = QtWidgets.QHBoxLayout()
|
|
disp_row.addWidget(self.display_cb)
|
|
disp_row.addWidget(self.seconds_sb)
|
|
form.addRow("Platform", self.platform_cb)
|
|
form.addRow("Mode", self.mode_cb)
|
|
form.addRow("Disk format", self.format_cb)
|
|
form.addRow("Palette", self.palette_cb)
|
|
form.addRow("Dither", self.dither_cb)
|
|
form.addRow("Mono/hue base", self.base_cb)
|
|
form.addRow("Video", self.video_cb)
|
|
form.addRow("Aspect", self.aspect_cb)
|
|
form.addRow("Display", disp_row)
|
|
form.addRow("Viewer", self.layout_cb)
|
|
panel.addLayout(form)
|
|
self.platform_cb.currentIndexChanged.connect(self._on_platform_changed)
|
|
|
|
self.intensive_cb = QtWidgets.QCheckBox("Intensive analysis (slower, best quality)")
|
|
self.intensive_cb.setChecked(True) # default on -- best quality
|
|
self.intensive_cb.stateChanged.connect(self.schedule_convert)
|
|
panel.addWidget(self.intensive_cb)
|
|
|
|
self.sliders = {}
|
|
self._slider_defaults = {}
|
|
for key, lo, hi, default, label in [
|
|
("brightness", 50, 200, 100, "bri"), ("contrast", 50, 200, 100, "con"),
|
|
("saturation", 0, 200, 100, "sat"), ("gamma", 50, 200, 100, "gam"),
|
|
("tint", -180, 180, 0, "tint"),
|
|
("red", 0, 200, 100, "red"), ("green", 0, 200, 100, "grn"),
|
|
("blue", 0, 200, 100, "blue")]:
|
|
self._slider_defaults[key] = default
|
|
panel.addLayout(self._slider(key, lo, hi, default, label))
|
|
|
|
reset_btn = QtWidgets.QPushButton("Reset adjustments")
|
|
reset_btn.setToolTip("Reset brightness/contrast/tint/levels to defaults.")
|
|
reset_btn.clicked.connect(self.reset_sliders)
|
|
panel.addWidget(reset_btn)
|
|
|
|
# ---- slideshow queue ----
|
|
self.add_slide_btn = QtWidgets.QPushButton("Add to slideshow")
|
|
self.add_slide_btn.setToolTip("Add the current image + its settings as a "
|
|
"slide in the slideshow queue.")
|
|
self.add_slide_btn.clicked.connect(self.add_to_slideshow)
|
|
self.add_slide_btn.setEnabled(False)
|
|
panel.addWidget(self.add_slide_btn)
|
|
self.slideshow_btn = QtWidgets.QPushButton("Slideshow (0)...")
|
|
self.slideshow_btn.setToolTip("Arrange the queued images, size them against "
|
|
"a disk, set advancing, and export one drive image.")
|
|
self.slideshow_btn.clicked.connect(self.open_slideshow)
|
|
panel.addWidget(self.slideshow_btn)
|
|
|
|
for cb in (self.mode_cb, self.format_cb, self.palette_cb, self.dither_cb,
|
|
self.base_cb, self.aspect_cb):
|
|
cb.currentIndexChanged.connect(self.schedule_convert)
|
|
|
|
panel.addStretch(1)
|
|
self.vice_btn = QtWidgets.QPushButton("Run in emulator")
|
|
self.vice_btn.setToolTip("Build a temporary disk/cartridge and boot it "
|
|
"in MAME.")
|
|
self.vice_btn.clicked.connect(self.run_in_vice)
|
|
self.vice_btn.setEnabled(False)
|
|
panel.addWidget(self.vice_btn)
|
|
|
|
self.export_btn = QtWidgets.QPushButton("Export disk image...")
|
|
self.export_btn.clicked.connect(self.export)
|
|
self.export_btn.setEnabled(False)
|
|
panel.addWidget(self.export_btn)
|
|
|
|
self.status = self.statusBar()
|
|
self._temp_disks = []
|
|
self._emu_proc = None
|
|
|
|
def _make_preview(self, title):
|
|
box = QtWidgets.QGroupBox(title)
|
|
lay = QtWidgets.QVBoxLayout(box)
|
|
label = QtWidgets.QLabel("(no image)")
|
|
label.setAlignment(QtCore.Qt.AlignCenter)
|
|
label.setMinimumSize(320, 200)
|
|
label.setStyleSheet("background:#202020;color:#888;")
|
|
lay.addWidget(label)
|
|
return {"box": box, "label": label}
|
|
|
|
def _combo(self, items, default):
|
|
cb = QtWidgets.QComboBox()
|
|
cb.addItems(items)
|
|
if default in items:
|
|
cb.setCurrentText(default)
|
|
return cb
|
|
|
|
def _slider(self, key, lo, hi, default=100, label=None):
|
|
row = QtWidgets.QHBoxLayout()
|
|
lab = QtWidgets.QLabel(label or key[:4])
|
|
lab.setMinimumWidth(34)
|
|
row.addWidget(lab)
|
|
s = QtWidgets.QSlider(QtCore.Qt.Horizontal)
|
|
s.setRange(lo, hi)
|
|
s.setValue(default)
|
|
s.sliderReleased.connect(self.schedule_convert)
|
|
self.sliders[key] = s
|
|
row.addWidget(s)
|
|
return row
|
|
|
|
def reset_sliders(self):
|
|
"""Reset every adjustment slider to its default and re-render once."""
|
|
for key, slider in self.sliders.items():
|
|
slider.blockSignals(True)
|
|
slider.setValue(self._slider_defaults[key])
|
|
slider.blockSignals(False)
|
|
self.schedule_convert()
|
|
|
|
def _check_tools(self):
|
|
from . import mame
|
|
missing = []
|
|
if not have_xa():
|
|
missing.append("xa (xa65 assembler, for the 6502 viewers)")
|
|
if not have_c1541():
|
|
missing.append("c1541 (VICE package, builds C64 disks)")
|
|
if not mame.have_mame():
|
|
missing.append("mame (runs every platform's image)")
|
|
if missing:
|
|
QtWidgets.QMessageBox.warning(
|
|
self, "Missing tools",
|
|
"Export / Run need these tools on PATH:\n " + "\n ".join(missing) +
|
|
"\n\nInstall: sudo apt install xa65 vice mame")
|
|
|
|
def _on_platform_changed(self):
|
|
"""Repopulate platform-specific choices and re-render."""
|
|
plat = self.platform_cb.currentData()
|
|
for cb, items in ((self.mode_cb, platforms.modes(plat)),
|
|
(self.palette_cb, platforms.palettes(plat)),
|
|
(self.format_cb, platforms.disk_formats(plat))):
|
|
cb.blockSignals(True)
|
|
cb.clear()
|
|
cb.addItems(items)
|
|
cb.blockSignals(False)
|
|
self._gallery_key = None # invalidate variations cache
|
|
self._retarget_slideshow(plat) # keep the slideshow on the selected platform
|
|
self.schedule_convert()
|
|
|
|
# ---- params ----
|
|
def params(self):
|
|
return {
|
|
"platform": self.platform_cb.currentData(),
|
|
"mode": self.mode_cb.currentText(),
|
|
"palette": self.palette_cb.currentText(),
|
|
"dither": self.dither_cb.currentText(),
|
|
"base_name": self.base_cb.currentText(),
|
|
"aspect": self.aspect_cb.currentText(),
|
|
"intensive": self.intensive_cb.isChecked(),
|
|
"brightness": self.sliders["brightness"].value() / 100.0,
|
|
"contrast": self.sliders["contrast"].value() / 100.0,
|
|
"saturation": self.sliders["saturation"].value() / 100.0,
|
|
"gamma": self.sliders["gamma"].value() / 100.0,
|
|
"tint": float(self.sliders["tint"].value()), # degrees
|
|
"red": self.sliders["red"].value() / 100.0,
|
|
"green": self.sliders["green"].value() / 100.0,
|
|
"blue": self.sliders["blue"].value() / 100.0,
|
|
}
|
|
|
|
# ---- actions ----
|
|
def open_image(self):
|
|
path, _ = QtWidgets.QFileDialog.getOpenFileName(
|
|
self, "Open image", "",
|
|
"Images (*.png *.jpg *.jpeg *.gif *.bmp *.webp *.tif *.tiff);;"
|
|
"All files (*)")
|
|
if path:
|
|
self.load_path(path)
|
|
|
|
def load_path(self, path):
|
|
"""Load an image (from the file chooser or a drag-and-drop) and convert."""
|
|
# Load via Pillow, not QPixmap: Qt's bundled plugins can't read webp/tiff
|
|
# (and ignore EXIF orientation), so those originals showed up blank.
|
|
try:
|
|
from PIL import Image, ImageOps
|
|
im = ImageOps.exif_transpose(Image.open(path)).convert("RGB")
|
|
pm = numpy_to_pixmap(np.asarray(im, dtype=np.uint8))
|
|
except Exception:
|
|
QtWidgets.QMessageBox.warning(
|
|
self, "Cannot open image",
|
|
f"Could not read this image:\n{path}\n\n{traceback.format_exc()}")
|
|
return
|
|
self.source_path = path
|
|
self.explore_btn.setEnabled(True)
|
|
self.src_label["label"].setPixmap(
|
|
pm.scaled(self.src_label["label"].size(), QtCore.Qt.KeepAspectRatio,
|
|
QtCore.Qt.SmoothTransformation))
|
|
self.schedule_convert()
|
|
|
|
# ---- drag and drop ----
|
|
@staticmethod
|
|
def _dropped_image_path(mime):
|
|
"""Return the first local image-file path in a drag, or None."""
|
|
exts = (".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp", ".tif", ".tiff")
|
|
if not mime.hasUrls():
|
|
return None
|
|
for url in mime.urls():
|
|
if url.isLocalFile() and url.toLocalFile().lower().endswith(exts):
|
|
return url.toLocalFile()
|
|
return None
|
|
|
|
def dragEnterEvent(self, event):
|
|
if self._dropped_image_path(event.mimeData()):
|
|
event.acceptProposedAction()
|
|
|
|
def dragMoveEvent(self, event):
|
|
if self._dropped_image_path(event.mimeData()):
|
|
event.acceptProposedAction()
|
|
|
|
def dropEvent(self, event):
|
|
path = self._dropped_image_path(event.mimeData())
|
|
if path:
|
|
event.acceptProposedAction()
|
|
self.load_path(path)
|
|
|
|
def explore_variations(self):
|
|
if not self.source_path:
|
|
return
|
|
p = self.params()
|
|
prep_kwargs = dict(aspect=p["aspect"], brightness=p["brightness"],
|
|
contrast=p["contrast"], saturation=p["saturation"],
|
|
gamma=p["gamma"], tint=p["tint"], red=p["red"],
|
|
green=p["green"], blue=p["blue"])
|
|
plat, base = p["platform"], p["base_name"]
|
|
# Reuse cached results when source, platform, base and prep are unchanged.
|
|
key = (self.source_path, plat, base, tuple(sorted(prep_kwargs.items())))
|
|
cached = self._gallery_results if self._gallery_key == key else None
|
|
dlg = VariationsDialog(self.source_path, prep_kwargs, plat, base, self,
|
|
cached=cached)
|
|
result = dlg.exec_()
|
|
if len(dlg.collected) == len(gallery.combos(plat)):
|
|
self._gallery_key = key
|
|
self._gallery_results = dict(dlg.collected)
|
|
if result == QtWidgets.QDialog.Accepted and dlg.choice:
|
|
mode, palette, dither = dlg.choice
|
|
# apply choice, then let the focused live preview + tuning take over
|
|
for cb in (self.mode_cb, self.palette_cb, self.dither_cb):
|
|
cb.blockSignals(True)
|
|
self.mode_cb.setCurrentText(mode)
|
|
self.palette_cb.setCurrentText(palette)
|
|
self.dither_cb.setCurrentText(dither)
|
|
for cb in (self.mode_cb, self.palette_cb, self.dither_cb):
|
|
cb.blockSignals(False)
|
|
self.status.showMessage(
|
|
f"Chosen: {mode} / {palette} / {dither}. "
|
|
f"Now fine-tune brightness/contrast/saturation/gamma.")
|
|
self.schedule_convert()
|
|
|
|
# ---- slideshow ----
|
|
def _update_slideshow_btn(self):
|
|
self.slideshow_btn.setText(f"Slideshow ({len(self._slides)})...")
|
|
|
|
def _retarget_slideshow(self, new_plat):
|
|
"""Re-convert the queued slides so the slideshow follows the main platform.
|
|
|
|
Called when the platform changes: each slide keeps its source image, image
|
|
adjustments (prep) and dither, but its mode/palette fall back to the new
|
|
platform's defaults when the old ones don't exist there. Converts every
|
|
slide up front and only commits if all succeed, so a failure leaves the
|
|
queue untouched. No-op unless there is a queue and the new platform can
|
|
host a slideshow (switching to a non-slideshow platform leaves it as-is)."""
|
|
if (not self._slides or new_plat == self._slideshow_platform
|
|
or not slideshow.supports_slideshow(new_plat)):
|
|
return
|
|
modes, palettes = platforms.modes(new_plat), platforms.palettes(new_plat)
|
|
intensive = self.intensive_cb.isChecked()
|
|
QtWidgets.QApplication.setOverrideCursor(QtCore.Qt.WaitCursor)
|
|
try:
|
|
rebuilt = []
|
|
for s in self._slides:
|
|
old = s["item"]
|
|
mode = old.mode if old.mode in modes else modes[0]
|
|
palette = old.palette if old.palette in palettes else palettes[0]
|
|
conv = platforms.convert(new_plat, old.source_path, mode, palette,
|
|
old.dither, intensive, old.prep, old.mono_base)
|
|
item = slideshow.SlideItem(
|
|
source_path=old.source_path, mode=mode, palette=palette,
|
|
dither=old.dither, mono_base=old.mono_base, prep=old.prep)
|
|
thumb = render_preview(conv, conv.meta.get("palette", palette), scale=1)
|
|
rebuilt.append({"item": item, "conv": conv, "thumb": thumb})
|
|
except Exception:
|
|
QtWidgets.QApplication.restoreOverrideCursor()
|
|
QtWidgets.QMessageBox.warning(
|
|
self, "Could not retarget slideshow",
|
|
"The queued images could not all be converted for "
|
|
f"{PLATFORM_NAMES.get(new_plat, new_plat)}, so the slideshow was "
|
|
"left on the previous platform.\n\n" + traceback.format_exc())
|
|
return
|
|
QtWidgets.QApplication.restoreOverrideCursor()
|
|
self._slides[:] = rebuilt # mutate in place (shared reference)
|
|
self._slideshow_platform = new_plat
|
|
self.status.showMessage(
|
|
f"Slideshow retargeted to {PLATFORM_NAMES.get(new_plat, new_plat)} "
|
|
f"({len(rebuilt)} slide(s) re-converted).")
|
|
|
|
def add_to_slideshow(self):
|
|
if not self.last_conv or not self.source_path:
|
|
return
|
|
plat = self.platform_cb.currentData()
|
|
if not slideshow.supports_slideshow(plat):
|
|
QtWidgets.QMessageBox.warning(
|
|
self, "Not supported",
|
|
"Slideshows are only supported on disk-based platforms "
|
|
"(C64, C128, Atari, BBC, Apple, IIGS, Amiga).")
|
|
return
|
|
if self._slides and plat != self._slideshow_platform:
|
|
QtWidgets.QMessageBox.warning(
|
|
self, "One platform per slideshow",
|
|
f"This slideshow is for "
|
|
f"{PLATFORM_NAMES.get(self._slideshow_platform, self._slideshow_platform)}."
|
|
"\nRemove all its images before switching platforms.")
|
|
return
|
|
p = self.params()
|
|
prep = imageprep.PrepOptions(
|
|
aspect=p["aspect"], brightness=p["brightness"], contrast=p["contrast"],
|
|
saturation=p["saturation"], gamma=p["gamma"], tint=p["tint"],
|
|
red=p["red"], green=p["green"], blue=p["blue"])
|
|
item = slideshow.SlideItem(
|
|
source_path=self.source_path, mode=p["mode"], palette=p["palette"],
|
|
dither=p["dither"], mono_base=p["base_name"], prep=prep)
|
|
thumb = render_preview(self.last_conv,
|
|
self.last_conv.meta.get("palette", "colodore"), scale=1)
|
|
self._slides.append({"item": item, "conv": self.last_conv, "thumb": thumb})
|
|
self._slideshow_platform = plat
|
|
self._update_slideshow_btn()
|
|
self.status.showMessage(
|
|
f"Added slide {len(self._slides)} ({item.mode}/{item.dither}). "
|
|
f"{len(self._slides)} image(s) in the slideshow.")
|
|
|
|
def open_slideshow(self):
|
|
if not self._slides:
|
|
QtWidgets.QMessageBox.information(
|
|
self, "Slideshow empty",
|
|
"Add images first with the “Add to slideshow” button.")
|
|
return
|
|
dlg = SlideshowDialog(self._slides, self._slideshow_platform, self)
|
|
dlg.exec_()
|
|
if not self._slides:
|
|
self._slideshow_platform = None
|
|
self._update_slideshow_btn()
|
|
|
|
def schedule_convert(self):
|
|
if not self.source_path:
|
|
return
|
|
if self.worker and self.worker.isRunning():
|
|
self.pending = True # coalesce: re-run when current finishes
|
|
return
|
|
self.status.showMessage("Converting...")
|
|
self.worker = ConvertWorker(self.source_path, self.params())
|
|
self.worker.done.connect(self.on_converted)
|
|
self.worker.failed.connect(self.on_failed)
|
|
self.worker.finished.connect(self._worker_finished)
|
|
self.worker.start()
|
|
|
|
def _worker_finished(self):
|
|
if self.pending:
|
|
self.pending = False
|
|
self.schedule_convert()
|
|
|
|
def on_converted(self, conv):
|
|
self.last_conv = conv
|
|
rgb = render_preview(conv, conv.meta.get("palette", "colodore"), scale=2)
|
|
pm = numpy_to_pixmap(rgb)
|
|
self.c64_label["label"].setPixmap(
|
|
pm.scaled(self.c64_label["label"].size(), QtCore.Qt.KeepAspectRatio,
|
|
QtCore.Qt.SmoothTransformation))
|
|
self.export_btn.setEnabled(True)
|
|
self.vice_btn.setEnabled(platforms.has_emulator(self.platform_cb.currentData()))
|
|
self.add_slide_btn.setEnabled(
|
|
slideshow.supports_slideshow(self.platform_cb.currentData()))
|
|
self.status.showMessage(
|
|
f"{conv.mode}: mean dE {conv.error:.1f} "
|
|
f"(palette {conv.meta.get('palette')}, dither {conv.meta.get('dither')})")
|
|
|
|
def on_failed(self, msg):
|
|
self.status.showMessage("Conversion failed")
|
|
QtWidgets.QMessageBox.critical(self, "Conversion failed", msg)
|
|
|
|
def export(self):
|
|
if not self.last_conv:
|
|
return
|
|
plat = self.platform_cb.currentData()
|
|
fmt = self.format_cb.currentText()
|
|
suggested = os.path.splitext(os.path.basename(self.source_path or "picture"))[0]
|
|
path, _ = QtWidgets.QFileDialog.getSaveFileName(
|
|
self, "Export disk image", _home(f"{suggested}.{fmt}"),
|
|
f"Disk image (*.{fmt})")
|
|
if not path:
|
|
return
|
|
try:
|
|
out = platforms.export(plat, self.last_conv, path, fmt,
|
|
self.source_path, self.video_cb.currentText(),
|
|
display=self.display_cb.currentText(),
|
|
seconds=self.seconds_sb.value(),
|
|
layout=self.layout_cb.currentText())
|
|
self.status.showMessage(f"Wrote {out}")
|
|
hint = {"c64": ' LOAD"*",8,1 then RUN',
|
|
"ti99": " pick it from the TI menu (item 2)",
|
|
"ansi": " open it in an ANSI viewer, or display/upload it "
|
|
"on your BBS"}.get(
|
|
plat, " (self-booting -- just insert the disk/cartridge)")
|
|
QtWidgets.QMessageBox.information(
|
|
self, "Exported", f"Wrote {out}\n\nOn the machine / emulator:\n{hint}")
|
|
except Exception:
|
|
QtWidgets.QMessageBox.critical(self, "Export failed", traceback.format_exc())
|
|
|
|
def run_in_vice(self):
|
|
if not self.last_conv:
|
|
return
|
|
plat = self.platform_cb.currentData()
|
|
if not platforms.has_emulator(plat):
|
|
QtWidgets.QMessageBox.warning(
|
|
self, "Emulator not found",
|
|
"MAME was not found on PATH.\n"
|
|
"Install it with: sudo apt install mame")
|
|
return
|
|
try:
|
|
import tempfile
|
|
fmt = self.format_cb.currentText()
|
|
stem = os.path.splitext(os.path.basename(self.source_path or "picture"))[0]
|
|
fd, path = tempfile.mkstemp(suffix=f".{fmt}", prefix=f"{stem}_")
|
|
os.close(fd)
|
|
standard = self.video_cb.currentText()
|
|
platforms.export(plat, self.last_conv, path, fmt, self.source_path, standard,
|
|
display=self.display_cb.currentText(),
|
|
seconds=self.seconds_sb.value(),
|
|
layout=self.layout_cb.currentText())
|
|
self._temp_disks.append(path)
|
|
from . import mame
|
|
mame.kill(self._emu_proc) # close any previous MAME window
|
|
self._emu_proc = platforms.run_emulator(
|
|
plat, path, self.last_conv.mode, standard)
|
|
self.status.showMessage(f"Launched {plat} emulator ({standard}).")
|
|
except Exception:
|
|
QtWidgets.QMessageBox.critical(self, "Run in emulator failed",
|
|
traceback.format_exc())
|
|
|
|
def closeEvent(self, event):
|
|
from . import mame
|
|
mame.kill(self._emu_proc) # SIGKILL the emulator on exit
|
|
for path in self._temp_disks:
|
|
try:
|
|
os.remove(path)
|
|
except OSError:
|
|
pass
|
|
super().closeEvent(event)
|
|
|
|
|
|
def main(argv=None):
|
|
app = QtWidgets.QApplication(argv if argv is not None else sys.argv)
|
|
win = MainWindow()
|
|
win.show()
|
|
return app.exec_()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|