Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86da224448 | ||
|
|
29d2b75209 | ||
|
|
e000b296b5 | ||
|
|
cbce35441d | ||
|
|
11ae0e2f7d | ||
|
|
4d9058b9b2 |
No files matched your search
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
# No `submodules:` on the checkout above, so the tap is empty here and the
|
||||
# sdist below differs from a local `uv build` with the submodule populated.
|
||||
# The published asset is therefore the authority: tools/bump_packaging.py
|
||||
# The published asset is therefore the authority: bump_packaging.py
|
||||
# hashes the download, never a rebuild.
|
||||
- name: Build the sdist and wheel
|
||||
run: |
|
||||
@@ -64,6 +64,36 @@ jobs:
|
||||
uses: actions/forgejo-release@v2
|
||||
with:
|
||||
direction: upload
|
||||
# A re-run finds the release it made last time and would otherwise die
|
||||
# on `curl: (22) ... 409` when it tries to create it again. Override
|
||||
# deletes and recreates the release (the tag survives, since it still
|
||||
# matches the triggered SHA), so re-running this workflow is safe.
|
||||
override: true
|
||||
tag: ${{ forgejo.ref_name }}
|
||||
title: ${{ forgejo.ref_name }}
|
||||
release-notes-file: dist/notes.md
|
||||
|
||||
# Every packaging channel (AUR, the Homebrew tap and the flake) downloads
|
||||
# its source from tildeforge, so the release is published there as well.
|
||||
# That repository is not a mirror of this one: nothing reaches it unless
|
||||
# it is pushed, hence the tag push before the upload. Pushing a tag that
|
||||
# is already there and identical is a no-op, so this stays re-runnable.
|
||||
- name: Push the tag to tildeforge
|
||||
env:
|
||||
TILDEFORGE_TOKEN: ${{ secrets.TILDEFORGE_TOKEN }}
|
||||
run: |
|
||||
git push \
|
||||
"https://beefcarver:${TILDEFORGE_TOKEN}@tildeforge.dev/beefcarver/beefy-player.git" \
|
||||
"refs/tags/${FORGEJO_REF_NAME}"
|
||||
|
||||
- name: Publish the release to tildeforge
|
||||
uses: actions/forgejo-release@v2
|
||||
with:
|
||||
direction: upload
|
||||
url: https://tildeforge.dev
|
||||
repo: beefcarver/beefy-player
|
||||
token: ${{ secrets.TILDEFORGE_TOKEN }}
|
||||
override: true
|
||||
tag: ${{ forgejo.ref_name }}
|
||||
title: ${{ forgejo.ref_name }}
|
||||
release-notes-file: dist/notes.md
|
||||
@@ -35,7 +35,6 @@ git submodule update --init # contrib/homebrew, the Homebrew tap
|
||||
uv run pytest # full test suite
|
||||
uvx ruff check . # lint (ruff is not a project dependency)
|
||||
uv run beefy-player --list # station ids for the current catalogue
|
||||
uv run python tools/ansi_art.py ... # regenerate a provider's header art
|
||||
```
|
||||
|
||||
`uvx ruff check .` is the enforced lint and must pass. `ruff format` is **not**
|
||||
@@ -61,7 +60,7 @@ repo-wide reformat as a side effect of an unrelated change.
|
||||
`.forgejo/workflows/release.yml` fires on a `v*` tag: it asserts that the tag,
|
||||
the `pyproject.toml` version and `beefy_player.__version__` all agree, then
|
||||
publishes release notes. It does **not** touch packaging, so run
|
||||
`tools/bump_packaging.py <version>` after publishing: it rewrites
|
||||
`uv run python bump_packaging.py <version>` after publishing: it rewrites
|
||||
`contrib/aur/PKGBUILD` and regenerates its `.SRCINFO` from it, rewrites the tap
|
||||
formula in `contrib/homebrew`, and with `--commit` commits the tap first and
|
||||
then this repository. Both channels build the sdist attached to the release,
|
||||
|
||||
@@ -330,15 +330,12 @@ Each catalogue is a self-contained provider module under
|
||||
`beefy_player/providers/`; `base.py` documents the interface. Every catalogue's
|
||||
header is a wordmark from the shared half-block font, and the header paints it
|
||||
with the active palette gradient (`hero_gradient`, on by default) so all
|
||||
thirteen follow the terminal's colours. `tools/ansi_art.py` converts a PNG logo
|
||||
into half-block ANSI art that carries colours of its own; a provider using that
|
||||
art must set `hero_gradient = False`, because the header would otherwise
|
||||
repaint it with the gradient.
|
||||
thirteen follow the terminal's colours.
|
||||
|
||||
`uvx ruff check .` is the enforced lint. The tree is not `ruff format`-clean (54
|
||||
files would change), so do not reformat it wholesale.
|
||||
|
||||
`tools/bump_packaging.py <version>` keeps the AUR package and the Homebrew tap
|
||||
`uv run python bump_packaging.py <version>` keeps the AUR package and the Homebrew tap
|
||||
formula in step with a release — it rewrites `PKGBUILD`, regenerates
|
||||
`.SRCINFO` from it (no makepkg needed), rewrites the formula, and `--commit`s
|
||||
both. `--check` reports drift without writing.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Beefy Player — a flashy terminal player for internet radio."""
|
||||
|
||||
__version__ = "1.2.3"
|
||||
__version__ = "1.2.5"
|
||||
@@ -15,10 +15,10 @@ generated on the fly. The sdist's root directory is hatchling's PEP 503 name
|
||||
|
||||
Usage:
|
||||
|
||||
python tools/bump_packaging.py 1.2.3 # download the release asset
|
||||
python tools/bump_packaging.py 1.2.3 --asset dist/beefy_player-1.2.3.tar.gz
|
||||
python tools/bump_packaging.py 1.2.3 --commit # also commit both repos
|
||||
python tools/bump_packaging.py 1.2.3 --check # verify, write nothing
|
||||
python bump_packaging.py 1.2.3 # download the release asset
|
||||
python bump_packaging.py 1.2.3 --asset dist/beefy_player-1.2.3.tar.gz
|
||||
python bump_packaging.py 1.2.3 --commit # also commit both repos
|
||||
python bump_packaging.py 1.2.3 --check # verify, write nothing
|
||||
|
||||
`.SRCINFO` is never edited directly; it is regenerated by evaluating
|
||||
`PKGBUILD` in bash and serialising it in makepkg's field order. That keeps it
|
||||
@@ -106,7 +106,7 @@ done
|
||||
|
||||
#: Replaces the `_commit`-pinned git-archive source with the release asset.
|
||||
ASSET_SOURCE = (
|
||||
"# The sdist attached to the v$pkgver release. tools/bump_packaging.py keeps\n"
|
||||
"# The sdist attached to the v$pkgver release. bump_packaging.py keeps\n"
|
||||
"# this and sha256sums in step with the tag; build/check/package use\n"
|
||||
"# $_srcname because the sdist unpacks to hatchling's PEP 503 name.\n"
|
||||
'_srcname="beefy_player-$pkgver"\n'
|
||||
@@ -34,7 +34,7 @@
|
||||
in
|
||||
py.buildPythonApplication {
|
||||
pname = "beefy-player";
|
||||
version = "1.2.3";
|
||||
version = "1.2.5";
|
||||
pyproject = true;
|
||||
src = self;
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "beefy-player"
|
||||
version = "1.2.3"
|
||||
version = "1.2.5"
|
||||
description = "A flashy terminal player for internet radio — live spectrum analyser, MPRIS / macOS Now Playing support, song history and pluggable station catalogues (SomaFM, Radio Paradise, dublab, Rinse FM and more)"
|
||||
readme = "README.md"
|
||||
license = "WTFPL"
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Turn a logo into half-block ANSI art for the header hero.
|
||||
|
||||
The header hero is a handful of text rows, so an image has to survive a brutal
|
||||
downsample: two pixels per cell (upper/lower half block), one ANSI colour per
|
||||
half. Colours are quantised to the stock 16-colour ANSI palette on purpose —
|
||||
the rest of the UI never emits RGB, and a hero built from palette names still
|
||||
reads as the station's art in any terminal theme.
|
||||
|
||||
Shipped catalogues no longer use image art: each one renders a wordmark from
|
||||
beefy_player/providers/wordmark.py, which the header repaints with the active
|
||||
palette gradient (RadioProvider.hero_gradient, on by default). Art from this
|
||||
tool carries colours of its own, so a provider that uses it must set
|
||||
hero_gradient = False — otherwise the header repaints the art with the gradient
|
||||
and the palette names below are wasted.
|
||||
|
||||
Usage:
|
||||
|
||||
python tools/ansi_art.py logo.png --cols 22 --rows 7
|
||||
python tools/ansi_art.py --url https://example.org/logo.png \\
|
||||
--cols 22 --rows 7 --module beefy_player/providers/<slug>/hero.py
|
||||
|
||||
``--preview out.png`` draws what the cells will look like, which is much easier
|
||||
to judge than a wall of ▀ and ▄.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
#: The stock xterm colours the names below resolve to in a terminal.
|
||||
ANSI16: dict[str, tuple[int, int, int]] = {
|
||||
"black": (0, 0, 0),
|
||||
"red": (170, 0, 0),
|
||||
"green": (0, 170, 0),
|
||||
"yellow": (170, 85, 0),
|
||||
"blue": (0, 0, 170),
|
||||
"magenta": (170, 0, 170),
|
||||
"cyan": (0, 170, 170),
|
||||
"white": (170, 170, 170),
|
||||
"bright_black": (85, 85, 85),
|
||||
"bright_red": (255, 85, 85),
|
||||
"bright_green": (85, 255, 85),
|
||||
"bright_yellow": (255, 255, 85),
|
||||
"bright_blue": (85, 85, 255),
|
||||
"bright_magenta": (255, 85, 255),
|
||||
"bright_cyan": (85, 255, 255),
|
||||
"bright_white": (255, 255, 255),
|
||||
}
|
||||
|
||||
#: Below this alpha a half-cell counts as empty (terminal background).
|
||||
ALPHA_CUTOFF = 110
|
||||
|
||||
#: Ink weaker than this is treated as fringe when cropping the image.
|
||||
CROP_CUTOFF = 96
|
||||
|
||||
|
||||
def nearest_colour(rgb: tuple[int, int, int]) -> str:
|
||||
best, best_distance = "white", float("inf")
|
||||
for name, reference in ANSI16.items():
|
||||
distance = sum((a - b) ** 2 for a, b in zip(rgb, reference, strict=True))
|
||||
if distance < best_distance:
|
||||
best, best_distance = name, distance
|
||||
return best
|
||||
|
||||
|
||||
def key_white(image: Image.Image, tolerance: int) -> Image.Image:
|
||||
"""Make near-white pixels transparent.
|
||||
|
||||
A logo exported on a white background (or one whose anti-aliasing bleeds
|
||||
white) otherwise survives the downsample as a glaring box.
|
||||
"""
|
||||
image = image.copy()
|
||||
pixels = image.load()
|
||||
width, height = image.size
|
||||
threshold = 255 - max(0, tolerance)
|
||||
for y in range(height):
|
||||
for x in range(width):
|
||||
red, green, blue, alpha = pixels[x, y]
|
||||
if alpha and min(red, green, blue) >= threshold:
|
||||
pixels[x, y] = (red, green, blue, 0)
|
||||
return image
|
||||
|
||||
|
||||
def resize_premultiplied(image: Image.Image, size: tuple[int, int]) -> Image.Image:
|
||||
"""Resize in premultiplied alpha, so transparent pixels cannot bleed in.
|
||||
|
||||
Plain ``Image.resize`` averages the RGB of fully transparent pixels (often
|
||||
white) with the surrounding ink, which is how a clean logo turns into a
|
||||
grey haze at 20x14.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
data = np.asarray(image.convert("RGBA"), dtype=np.float32)
|
||||
alpha = data[..., 3:4] / 255.0
|
||||
premultiplied = np.concatenate([data[..., :3] * alpha, data[..., 3:4]], axis=-1)
|
||||
small = Image.fromarray(premultiplied.astype(np.uint8), "RGBA").resize(
|
||||
size, Image.LANCZOS
|
||||
)
|
||||
out = np.asarray(small, dtype=np.float32)
|
||||
out_alpha = out[..., 3:4] / 255.0
|
||||
rgb = out[..., :3] / np.maximum(out_alpha, 1e-6)
|
||||
return Image.fromarray(
|
||||
np.concatenate([np.clip(rgb, 0, 255), out[..., 3:4]], axis=-1).astype(np.uint8),
|
||||
"RGBA",
|
||||
)
|
||||
|
||||
|
||||
def load_image(
|
||||
path: Path | None, url: str | None, key_white_background: bool = False
|
||||
) -> Image.Image:
|
||||
if path is None and url:
|
||||
import httpx
|
||||
|
||||
response = httpx.get(url, follow_redirects=True, timeout=30.0)
|
||||
response.raise_for_status()
|
||||
from io import BytesIO
|
||||
|
||||
image = Image.open(BytesIO(response.content))
|
||||
elif path is not None:
|
||||
image = Image.open(path)
|
||||
else: # pragma: no cover - argparse enforces one of the two
|
||||
raise SystemExit("pass an image path or --url")
|
||||
|
||||
image = image.convert("RGBA")
|
||||
if key_white_background:
|
||||
image = key_white(image, 24)
|
||||
# Trim the transparent margin: the art should fill the box it is given.
|
||||
opaque = image.getchannel("A").point(lambda value: 255 if value > CROP_CUTOFF else 0)
|
||||
box = opaque.getbbox()
|
||||
return image.crop(box) if box else image
|
||||
|
||||
|
||||
def art_rows(image: Image.Image, cols: int, rows: int) -> list[list[tuple[str, str, str]]]:
|
||||
"""(glyph, foreground, background) per cell, two source pixels per cell."""
|
||||
small = resize_premultiplied(image, (cols, rows * 2))
|
||||
pixels = small.load()
|
||||
grid: list[list[tuple[str, str, str]]] = []
|
||||
for row in range(rows):
|
||||
cells: list[tuple[str, str, str]] = []
|
||||
for column in range(cols):
|
||||
top = pixels[column, row * 2]
|
||||
bottom = pixels[column, row * 2 + 1]
|
||||
top_ink = top[3] >= ALPHA_CUTOFF
|
||||
bottom_ink = bottom[3] >= ALPHA_CUTOFF
|
||||
if not top_ink and not bottom_ink:
|
||||
cells.append((" ", "", ""))
|
||||
elif top_ink and bottom_ink:
|
||||
cells.append(("▀", nearest_colour(top[:3]), nearest_colour(bottom[:3])))
|
||||
elif top_ink:
|
||||
cells.append(("▀", nearest_colour(top[:3]), ""))
|
||||
else:
|
||||
cells.append(("▄", nearest_colour(bottom[:3]), ""))
|
||||
grid.append(cells)
|
||||
return grid
|
||||
|
||||
|
||||
def markup(grid: list[list[tuple[str, str, str]]]) -> list[str]:
|
||||
"""One rich-markup string per row, run-length encoded by style."""
|
||||
lines: list[str] = []
|
||||
for cells in grid:
|
||||
line: list[str] = []
|
||||
index = 0
|
||||
while index < len(cells):
|
||||
glyph, foreground, background = cells[index]
|
||||
if not background and glyph == " ":
|
||||
line.append(" ")
|
||||
index += 1
|
||||
continue
|
||||
run = [glyph]
|
||||
index += 1
|
||||
while index < len(cells) and cells[index] == (glyph, foreground, background):
|
||||
run.append(cells[index][0])
|
||||
index += 1
|
||||
style = " ".join(
|
||||
part for part in (foreground, f"on {background}" if background else "") if part
|
||||
)
|
||||
body = "".join(run)
|
||||
line.append(f"[{style}]{body}[/]" if style else body)
|
||||
lines.append("".join(line).rstrip())
|
||||
return lines
|
||||
|
||||
|
||||
def preview(grid: list[list[tuple[str, str, str]]], path: Path, cell: tuple[int, int]) -> None:
|
||||
width, height = cell
|
||||
image = Image.new("RGB", (len(grid[0]) * width, len(grid) * height), (24, 24, 24))
|
||||
draw = ImageDraw.Draw(image)
|
||||
for row, cells in enumerate(grid):
|
||||
for column, (glyph, foreground, background) in enumerate(cells):
|
||||
x, y = column * width, row * height
|
||||
if background:
|
||||
draw.rectangle([x, y, x + width - 1, y + height - 1], fill=ANSI16[background])
|
||||
if glyph == " ":
|
||||
continue
|
||||
colour = ANSI16.get(foreground, (140, 140, 140))
|
||||
if glyph == "▀":
|
||||
box = [x, y, x + width - 1, y + height // 2 - 1]
|
||||
else:
|
||||
box = [x, y + height // 2, x + width - 1, y + height - 1]
|
||||
draw.rectangle(box, fill=colour)
|
||||
image.resize((image.width * 3, image.height * 3), Image.NEAREST).save(path)
|
||||
|
||||
|
||||
MODULE_TEMPLATE = '''"""Generated header art — do not edit by hand.
|
||||
|
||||
{provenance}
|
||||
|
||||
Regenerate with::
|
||||
|
||||
{command}
|
||||
|
||||
Each row is rich markup; ``▀`` is a half block whose foreground is the top
|
||||
pixel and whose background is the pixel below it. Colours are stock ANSI
|
||||
palette names, so the art keeps the terminal's own palette.
|
||||
|
||||
The header paints every hero with the active palette gradient unless the
|
||||
provider opts out, so the class using this art must set
|
||||
``hero_gradient = False``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
HERO: tuple[str, ...] = (
|
||||
{rows}
|
||||
)
|
||||
'''
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("image", nargs="?", type=Path, help="local image file")
|
||||
parser.add_argument("--url", help="fetch the image from this URL instead")
|
||||
parser.add_argument("--cols", type=int, default=22, help="terminal columns (default 22)")
|
||||
parser.add_argument("--rows", type=int, default=7, help="terminal rows (default 7)")
|
||||
parser.add_argument("--module", type=Path, help="write a Python module with HERO")
|
||||
parser.add_argument("--preview", type=Path, help="write a PNG preview of the cells")
|
||||
parser.add_argument("--provenance", default="", help="source note for the module header")
|
||||
parser.add_argument(
|
||||
"--key-white",
|
||||
action="store_true",
|
||||
help="treat near-white pixels as transparent (logos on white)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
image = load_image(args.image, args.url, args.key_white)
|
||||
grid = art_rows(image, args.cols, args.rows)
|
||||
rows = markup(grid)
|
||||
|
||||
if args.preview:
|
||||
preview(grid, args.preview, (10, 20))
|
||||
|
||||
if args.module:
|
||||
source = args.provenance or args.url or str(args.image)
|
||||
command = "uv run python tools/ansi_art.py " + " ".join(sys.argv[1:])
|
||||
body = "\n".join(f" {row!r}," for row in rows)
|
||||
args.module.write_text(
|
||||
MODULE_TEMPLATE.format(
|
||||
provenance=f"Source: {source}",
|
||||
command=command,
|
||||
rows=body,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(f"wrote {args.module} ({len(rows)} rows x {args.cols} cols)")
|
||||
else:
|
||||
print("\n".join(rows))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in new issue
Block a user