Render covers with quadrant blocks for twice the horizontal detail
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
+96
-21
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/jpeg" // poster formats
|
||||
_ "image/png"
|
||||
"time"
|
||||
@@ -21,6 +22,7 @@ const (
|
||||
coverFailureTTL = 2 * time.Minute
|
||||
maxCoverCache = 300
|
||||
coverFetchLimit = 4
|
||||
flatCellError = 600 // squared RGB distance below which a cell is drawn flat
|
||||
)
|
||||
|
||||
// decodeCover decodes a JPEG or PNG poster and keeps a small copy.
|
||||
@@ -68,9 +70,11 @@ func boxScale(src image.Image, w, h int) *image.RGBA {
|
||||
return dst
|
||||
}
|
||||
|
||||
// coverView draws a poster with half-block characters: every cell shows two
|
||||
// vertically stacked pixels (foreground = upper, background = lower), so it
|
||||
// works in any terminal with colors, also over SSH.
|
||||
// coverView draws a poster with quadrant block characters: every cell shows
|
||||
// 2×2 pixels in two colors (the best split of the four pixels into a
|
||||
// foreground and a background group), which doubles the horizontal detail of
|
||||
// plain half blocks. The glyphs are all from the Block Elements range that
|
||||
// every terminal font has, and it works over SSH.
|
||||
type coverView struct {
|
||||
*tview.Box
|
||||
img *image.RGBA
|
||||
@@ -84,6 +88,18 @@ func newCoverView() *coverView { return &coverView{Box: tview.NewBox()} }
|
||||
func (c *coverView) setImage(img *image.RGBA) { c.img, c.note, c.scaled = img, "", nil }
|
||||
func (c *coverView) setNote(note string) { c.img, c.note, c.scaled = nil, note, nil }
|
||||
|
||||
// coverCells returns how many columns and rows a poster of the given aspect
|
||||
// needs inside w×h cells. A cell is about twice as tall as it is wide.
|
||||
func coverCells(imgW, imgH, w, h int) (cols, rows int) {
|
||||
cols = w
|
||||
rows = cols * imgH / imgW / 2
|
||||
if rows > h {
|
||||
rows = h
|
||||
cols = rows * 2 * imgW / imgH
|
||||
}
|
||||
return max(cols, 0), max(rows, 0)
|
||||
}
|
||||
|
||||
func (c *coverView) Draw(screen tcell.Screen) {
|
||||
c.Box.DrawForSubclass(screen, c)
|
||||
x, y, w, h := c.GetInnerRect()
|
||||
@@ -97,33 +113,92 @@ func (c *coverView) Draw(screen tcell.Screen) {
|
||||
return
|
||||
}
|
||||
|
||||
// Fit the poster into w columns by 2h pixel rows, keeping its aspect.
|
||||
b := c.img.Bounds()
|
||||
tw := w
|
||||
th := tw * b.Dy() / b.Dx()
|
||||
if th > 2*h {
|
||||
th = 2 * h
|
||||
tw = th * b.Dx() / b.Dy()
|
||||
}
|
||||
th &^= 1
|
||||
if tw < 1 || th < 2 {
|
||||
cols, rows := coverCells(b.Dx(), b.Dy(), w, h)
|
||||
if cols < 1 || rows < 1 {
|
||||
return
|
||||
}
|
||||
if c.scaled == nil || c.scaled.Bounds().Dx() != tw || c.scaled.Bounds().Dy() != th {
|
||||
c.scaled = boxScale(c.img, tw, th)
|
||||
if c.scaled == nil || c.scaled.Bounds().Dx() != 2*cols || c.scaled.Bounds().Dy() != 2*rows {
|
||||
c.scaled = boxScale(c.img, 2*cols, 2*rows)
|
||||
}
|
||||
px := func(px, py int) tcell.Color {
|
||||
i := c.scaled.PixOffset(px, py)
|
||||
return tcell.NewRGBColor(int32(c.scaled.Pix[i]), int32(c.scaled.Pix[i+1]), int32(c.scaled.Pix[i+2]))
|
||||
px := func(x, y int) color.RGBA {
|
||||
i := c.scaled.PixOffset(x, y)
|
||||
return color.RGBA{c.scaled.Pix[i], c.scaled.Pix[i+1], c.scaled.Pix[i+2], 255}
|
||||
}
|
||||
for row := 0; row < th/2; row++ {
|
||||
for col := 0; col < tw; col++ {
|
||||
style := tcell.StyleDefault.Foreground(px(col, 2*row)).Background(px(col, 2*row+1))
|
||||
screen.SetContent(x+col, y+row, '▀', nil, style)
|
||||
for row := 0; row < rows; row++ {
|
||||
for col := 0; col < cols; col++ {
|
||||
ch, fg, bg := quadCell([4]color.RGBA{
|
||||
px(2*col, 2*row), px(2*col+1, 2*row), // top left, top right
|
||||
px(2*col, 2*row+1), px(2*col+1, 2*row+1), // bottom left, bottom right
|
||||
})
|
||||
style := tcell.StyleDefault.Foreground(tcell.NewRGBColor(int32(fg.R), int32(fg.G), int32(fg.B))).
|
||||
Background(tcell.NewRGBColor(int32(bg.R), int32(bg.G), int32(bg.B)))
|
||||
screen.SetContent(x+col, y+row, ch, nil, style)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// quadrants[mask] is the glyph that fills the quadrants set in mask
|
||||
// (bit 0 top left, 1 top right, 2 bottom left, 3 bottom right).
|
||||
var quadrants = [16]rune{' ', '▘', '▝', '▀', '▖', '▌', '▞', '▛', '▗', '▚', '▐', '▜', '▄', '▙', '▟', '█'}
|
||||
|
||||
// quadCell picks the glyph and colors that best reproduce four pixels (top
|
||||
// left, top right, bottom left, bottom right) with one foreground and one
|
||||
// background color.
|
||||
func quadCell(p [4]color.RGBA) (ch rune, fg, bg color.RGBA) {
|
||||
mean := func(mask int, want bool) (c [3]float64, n int) {
|
||||
for i := 0; i < 4; i++ {
|
||||
if (mask>>i&1 == 1) == want {
|
||||
c[0], c[1], c[2] = c[0]+float64(p[i].R), c[1]+float64(p[i].G), c[2]+float64(p[i].B)
|
||||
n++
|
||||
}
|
||||
}
|
||||
for k := range c {
|
||||
if n > 0 {
|
||||
c[k] /= float64(n)
|
||||
}
|
||||
}
|
||||
return c, n
|
||||
}
|
||||
// err is the squared distance of the pixels in one group to the group's mean.
|
||||
errOf := func(mask int, want bool, m [3]float64) (e float64) {
|
||||
for i := 0; i < 4; i++ {
|
||||
if (mask>>i&1 == 1) == want {
|
||||
dr, dg, db := float64(p[i].R)-m[0], float64(p[i].G)-m[1], float64(p[i].B)-m[2]
|
||||
e += dr*dr + dg*dg + db*db
|
||||
}
|
||||
}
|
||||
return e
|
||||
}
|
||||
toRGBA := func(m [3]float64) color.RGBA {
|
||||
return color.RGBA{uint8(m[0] + .5), uint8(m[1] + .5), uint8(m[2] + .5), 255}
|
||||
}
|
||||
|
||||
// Mask 0 is a single flat color. A split must clearly beat it, and nearly
|
||||
// flat cells (smooth gradients, noise) stay flat. Only masks without the
|
||||
// bottom right quadrant are tried: a mask and its complement draw the same
|
||||
// picture with swapped colors.
|
||||
flat, _ := mean(0, false)
|
||||
flatErr := errOf(0, false, flat)
|
||||
best, bestErr := 0, flatErr*0.6
|
||||
if flatErr >= flatCellError {
|
||||
for mask := 1; mask < 8; mask++ {
|
||||
fm, _ := mean(mask, true)
|
||||
bm, _ := mean(mask, false)
|
||||
if e := errOf(mask, true, fm) + errOf(mask, false, bm); e < bestErr {
|
||||
best, bestErr = mask, e
|
||||
}
|
||||
}
|
||||
}
|
||||
if best == 0 {
|
||||
c := toRGBA(flat)
|
||||
return ' ', c, c
|
||||
}
|
||||
fm, _ := mean(best, true)
|
||||
bm, _ := mean(best, false)
|
||||
return quadrants[best], toRGBA(fm), toRGBA(bm)
|
||||
}
|
||||
|
||||
// coverRef says where a poster comes from. The zero value means "no poster".
|
||||
type coverRef struct {
|
||||
key string // identifies the poster in the cache
|
||||
|
||||
Reference in New Issue
Block a user