mirror of
https://github.com/YuzuZensai/TrollSSH.git
synced 2026-09-13 18:59:29 +00:00
♻️ refactor: split src into internal packages and cmd
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"container/list"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type cacheKey struct {
|
||||
setID int
|
||||
index int
|
||||
width int
|
||||
height int
|
||||
keepAspectRatio bool
|
||||
tier ColorTier
|
||||
}
|
||||
|
||||
type cacheShard struct {
|
||||
mu sync.Mutex
|
||||
maxBytes int64
|
||||
size int64
|
||||
entries map[cacheKey]*list.Element
|
||||
order *list.List
|
||||
}
|
||||
|
||||
type Cache struct {
|
||||
shards []cacheShard
|
||||
size atomic.Int64
|
||||
hits atomic.Uint64
|
||||
misses atomic.Uint64
|
||||
evictions atomic.Uint64
|
||||
rejections atomic.Uint64
|
||||
renders atomic.Uint64
|
||||
renderNs atomic.Uint64
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
key cacheKey
|
||||
ascii []byte
|
||||
cost int64
|
||||
}
|
||||
|
||||
func entryCost(_ cacheKey, ascii []byte) int64 {
|
||||
return int64(cap(ascii)) + 160
|
||||
}
|
||||
|
||||
func NewCache(maxBytes int64) *Cache {
|
||||
if maxBytes <= 0 {
|
||||
return nil
|
||||
}
|
||||
shardCount := int(min(int64(16), max(int64(1), maxBytes/(1<<20))))
|
||||
cache := &Cache{shards: make([]cacheShard, shardCount)}
|
||||
for i := range cache.shards {
|
||||
cache.shards[i] = cacheShard{
|
||||
maxBytes: maxBytes / int64(shardCount),
|
||||
entries: make(map[cacheKey]*list.Element),
|
||||
order: list.New(),
|
||||
}
|
||||
}
|
||||
return cache
|
||||
}
|
||||
|
||||
func (c *Cache) shard(key cacheKey) *cacheShard {
|
||||
hash := uint64(key.setID)*0x9e3779b185ebca87 ^ uint64(key.index)*0xc2b2ae3d27d4eb4f
|
||||
hash ^= uint64(key.width)<<32 | uint64(uint32(key.height))
|
||||
hash ^= uint64(key.tier)<<1 | uint64(boolToInt(key.keepAspectRatio))
|
||||
return &c.shards[hash%uint64(len(c.shards))]
|
||||
}
|
||||
|
||||
func boolToInt(value bool) int {
|
||||
if value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (c *Cache) get(key cacheKey) ([]byte, bool) {
|
||||
if c == nil {
|
||||
return nil, false
|
||||
}
|
||||
shard := c.shard(key)
|
||||
shard.mu.Lock()
|
||||
defer shard.mu.Unlock()
|
||||
el, ok := shard.entries[key]
|
||||
if !ok {
|
||||
c.misses.Add(1)
|
||||
return nil, false
|
||||
}
|
||||
c.hits.Add(1)
|
||||
shard.order.MoveToBack(el)
|
||||
return el.Value.(*cacheEntry).ascii, true
|
||||
}
|
||||
|
||||
func (c *Cache) put(key cacheKey, ascii []byte) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
shard := c.shard(key)
|
||||
cost := entryCost(key, ascii)
|
||||
if cost > shard.maxBytes {
|
||||
c.rejections.Add(1)
|
||||
return
|
||||
}
|
||||
shard.mu.Lock()
|
||||
defer shard.mu.Unlock()
|
||||
if _, ok := shard.entries[key]; ok {
|
||||
return
|
||||
}
|
||||
shard.entries[key] = shard.order.PushBack(&cacheEntry{key: key, ascii: ascii, cost: cost})
|
||||
shard.size += cost
|
||||
c.size.Add(cost)
|
||||
for shard.size > shard.maxBytes {
|
||||
oldest := shard.order.Front()
|
||||
shard.order.Remove(oldest)
|
||||
evicted := oldest.Value.(*cacheEntry)
|
||||
delete(shard.entries, evicted.key)
|
||||
shard.size -= evicted.cost
|
||||
c.size.Add(-evicted.cost)
|
||||
c.evictions.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
type CacheStats struct {
|
||||
SizeBytes int64
|
||||
Hits uint64
|
||||
Misses uint64
|
||||
Evictions uint64
|
||||
Rejections uint64
|
||||
Renders uint64
|
||||
RenderTime time.Duration
|
||||
}
|
||||
|
||||
func (c *Cache) Stats() CacheStats {
|
||||
if c == nil {
|
||||
return CacheStats{}
|
||||
}
|
||||
return CacheStats{
|
||||
SizeBytes: c.size.Load(),
|
||||
Hits: c.hits.Load(),
|
||||
Misses: c.misses.Load(),
|
||||
Evictions: c.evictions.Load(),
|
||||
Rejections: c.rejections.Load(),
|
||||
Renders: c.renders.Load(),
|
||||
RenderTime: time.Duration(c.renderNs.Load()),
|
||||
}
|
||||
}
|
||||
|
||||
type Renderer struct {
|
||||
setID int
|
||||
colorFrames [][]byte
|
||||
options Options
|
||||
rampLUT *[101][]byte
|
||||
cache *Cache
|
||||
|
||||
inflightMu sync.Mutex
|
||||
inflight map[cacheKey]*renderCall
|
||||
}
|
||||
|
||||
type renderCall struct {
|
||||
done chan struct{}
|
||||
value []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func NewRenderer(setID int, colorFrames [][]byte, options Options, cache *Cache) *Renderer {
|
||||
ramp := []rune(resolveCharset(options.Charset))
|
||||
return &Renderer{
|
||||
setID: setID,
|
||||
colorFrames: colorFrames,
|
||||
options: options,
|
||||
rampLUT: buildRampLUT(ramp, options),
|
||||
cache: cache,
|
||||
inflight: make(map[cacheKey]*renderCall),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Renderer) Render(index, width, height int, keepAspectRatio bool, tier ColorTier) ([]byte, error) {
|
||||
key := cacheKey{r.setID, index, width, height, keepAspectRatio, tier}
|
||||
if ascii, ok := r.cache.get(key); ok {
|
||||
return ascii, nil
|
||||
}
|
||||
|
||||
r.inflightMu.Lock()
|
||||
if call, ok := r.inflight[key]; ok {
|
||||
r.inflightMu.Unlock()
|
||||
<-call.done
|
||||
return call.value, call.err
|
||||
}
|
||||
call := &renderCall{done: make(chan struct{})}
|
||||
r.inflight[key] = call
|
||||
r.inflightMu.Unlock()
|
||||
defer func() {
|
||||
r.inflightMu.Lock()
|
||||
delete(r.inflight, key)
|
||||
r.inflightMu.Unlock()
|
||||
close(call.done)
|
||||
}()
|
||||
|
||||
if ascii, ok := r.cache.get(key); ok {
|
||||
call.value = ascii
|
||||
return ascii, nil
|
||||
}
|
||||
|
||||
started := time.Now()
|
||||
pix := getPixBuf(4 * width * height)
|
||||
img, err := resizeFrame(r.colorFrames[index], pix, width, height, keepAspectRatio)
|
||||
if err != nil {
|
||||
putPixBuf(pix)
|
||||
call.err = err
|
||||
return nil, err
|
||||
}
|
||||
var ascii []byte
|
||||
if tier == ColorTierNone {
|
||||
ascii = frameToAscii(img, r.rampLUT)
|
||||
} else {
|
||||
ascii = frameToAnsi(img, r.rampLUT, tier)
|
||||
}
|
||||
putPixBuf(pix)
|
||||
|
||||
if r.cache != nil && cap(ascii) > len(ascii)+len(ascii)/4 {
|
||||
ascii = bytes.Clone(ascii)
|
||||
}
|
||||
r.cache.put(key, ascii)
|
||||
if r.cache != nil {
|
||||
r.cache.renders.Add(1)
|
||||
r.cache.renderNs.Add(uint64(time.Since(started)))
|
||||
}
|
||||
call.value = ascii
|
||||
return ascii, nil
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
|
||||
type ColorTier int
|
||||
|
||||
const (
|
||||
ColorTierNone ColorTier = iota
|
||||
ColorTier256
|
||||
ColorTierTrueColor
|
||||
)
|
||||
|
||||
func DetectColorTier(term string) ColorTier {
|
||||
t := strings.ToLower(strings.TrimSpace(term))
|
||||
switch t {
|
||||
case "", "dumb", "vt52", "vt100", "vt102", "vt220", "ansi", "linux", "cons25", "cygwin":
|
||||
return ColorTierNone
|
||||
}
|
||||
if strings.Contains(t, "direct") || strings.Contains(t, "truecolor") {
|
||||
return ColorTierTrueColor
|
||||
}
|
||||
if strings.Contains(t, "256color") {
|
||||
return ColorTier256
|
||||
}
|
||||
if strings.HasPrefix(t, "screen") || strings.HasPrefix(t, "tmux") {
|
||||
return ColorTier256
|
||||
}
|
||||
return ColorTierTrueColor
|
||||
}
|
||||
|
||||
var charsetPresets = map[string]string{
|
||||
"detailed": " .'`^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
|
||||
"standard": " .:-=+*#%@",
|
||||
"simple": " .:oO#@",
|
||||
"blocks": " ░▒▓█",
|
||||
}
|
||||
|
||||
func resolveCharset(charset string) string {
|
||||
if charset == "" {
|
||||
return charsetPresets["detailed"]
|
||||
}
|
||||
if preset, ok := charsetPresets[strings.ToLower(charset)]; ok {
|
||||
return preset
|
||||
}
|
||||
return charset
|
||||
}
|
||||
|
||||
const maxPooledBuffer = 4 << 20
|
||||
|
||||
var pixPool sync.Pool
|
||||
|
||||
func getPixBuf(n int) []byte {
|
||||
if v := pixPool.Get(); v != nil {
|
||||
if b := *v.(*[]byte); cap(b) >= n {
|
||||
return b[:n]
|
||||
}
|
||||
}
|
||||
return make([]byte, n)
|
||||
}
|
||||
|
||||
func putPixBuf(b []byte) {
|
||||
if cap(b) <= maxPooledBuffer {
|
||||
pixPool.Put(&b)
|
||||
}
|
||||
}
|
||||
|
||||
var outPool sync.Pool
|
||||
|
||||
func getOutBuf(capacity int) []byte {
|
||||
if v := outPool.Get(); v != nil {
|
||||
if b := *v.(*[]byte); cap(b) >= capacity {
|
||||
return b[:0]
|
||||
}
|
||||
}
|
||||
return make([]byte, 0, capacity)
|
||||
}
|
||||
|
||||
func putOutBuf(b []byte) {
|
||||
if cap(b) <= maxPooledBuffer {
|
||||
outPool.Put(&b)
|
||||
}
|
||||
}
|
||||
|
||||
func resizeFrame(frame, pix []byte, width, height int, keepAspectRatio bool) (*image.RGBA, error) {
|
||||
src, err := jpeg.Decode(bytes.NewReader(frame))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rect := image.Rect(0, 0, width, height)
|
||||
|
||||
dst := &image.RGBA{Pix: pix[:4*width*height], Stride: 4 * width, Rect: rect}
|
||||
if keepAspectRatio {
|
||||
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.Black), image.Point{}, draw.Src)
|
||||
sb := src.Bounds()
|
||||
sw, sh := sb.Dx(), sb.Dy()
|
||||
scale := min(float64(width)/float64(sw), float64(height)/float64(sh))
|
||||
tw := max(1, int(float64(sw)*scale))
|
||||
th := max(1, int(float64(sh)*scale))
|
||||
x0 := (width - tw) / 2
|
||||
y0 := (height - th) / 2
|
||||
draw.ApproxBiLinear.Scale(dst, image.Rect(x0, y0, x0+tw, y0+th), src, sb, draw.Src, nil)
|
||||
} else {
|
||||
draw.ApproxBiLinear.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Src, nil)
|
||||
}
|
||||
return dst, nil
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
BrightnessThreshold int
|
||||
Charset string
|
||||
Invert bool
|
||||
}
|
||||
|
||||
func buildRampLUT(ramp []rune, options Options) *[101][]byte {
|
||||
var lut [101][]byte
|
||||
for b := range lut {
|
||||
index := rampIndex(b, options.BrightnessThreshold, len(ramp), options.Invert)
|
||||
lut[b] = utf8.AppendRune(nil, ramp[index])
|
||||
}
|
||||
return &lut
|
||||
}
|
||||
|
||||
func rampIndex(brightness, threshold, total int, invert bool) int {
|
||||
var index int
|
||||
if brightness < threshold {
|
||||
index = 0
|
||||
} else {
|
||||
index = brightness * total / 100
|
||||
if index > total-1 {
|
||||
index = total - 1
|
||||
}
|
||||
}
|
||||
if invert {
|
||||
index = total - 1 - index
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
func frameToAscii(img *image.RGBA, rampLUT *[101][]byte) []byte {
|
||||
pix := img.Pix
|
||||
maxCharBytes := 1
|
||||
for _, char := range rampLUT {
|
||||
maxCharBytes = max(maxCharBytes, len(char))
|
||||
}
|
||||
buf := getOutBuf(len(pix) / 4 * maxCharBytes)
|
||||
for o := 0; o < len(pix); o += 4 {
|
||||
brightness := (int(pix[o])*299 + int(pix[o+1])*587 + int(pix[o+2])*114) / 255 / 10
|
||||
buf = append(buf, rampLUT[brightness]...)
|
||||
}
|
||||
output := bytes.Clone(buf)
|
||||
putOutBuf(buf)
|
||||
return output
|
||||
}
|
||||
|
||||
const ansiReset = "\x1b[0m"
|
||||
|
||||
var ansi256Levels = [6]int{0, 95, 135, 175, 215, 255}
|
||||
|
||||
var decimal = func() (t [256]string) {
|
||||
for i := range t {
|
||||
t[i] = strconv.Itoa(i)
|
||||
}
|
||||
return
|
||||
}()
|
||||
|
||||
var ansi256Cube = func() (t [256]uint8) {
|
||||
for v := range t {
|
||||
best, bestDist := 0, 1<<30
|
||||
for i, l := range ansi256Levels {
|
||||
d := v - l
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
if d < bestDist {
|
||||
bestDist, best = d, i
|
||||
}
|
||||
}
|
||||
t[v] = uint8(best)
|
||||
}
|
||||
return
|
||||
}()
|
||||
|
||||
func quantize256(r, g, b uint8) int {
|
||||
return 16 + 36*int(ansi256Cube[r]) + 6*int(ansi256Cube[g]) + int(ansi256Cube[b])
|
||||
}
|
||||
|
||||
func appendColor(buf []byte, r, g, b uint8, tier ColorTier) []byte {
|
||||
if tier == ColorTierTrueColor {
|
||||
buf = append(buf, "\x1b[38;2;"...)
|
||||
buf = append(buf, decimal[r]...)
|
||||
buf = append(buf, ';')
|
||||
buf = append(buf, decimal[g]...)
|
||||
buf = append(buf, ';')
|
||||
buf = append(buf, decimal[b]...)
|
||||
} else {
|
||||
buf = append(buf, "\x1b[38;5;"...)
|
||||
buf = append(buf, decimal[quantize256(r, g, b)]...)
|
||||
}
|
||||
return append(buf, 'm')
|
||||
}
|
||||
|
||||
func frameToAnsi(img *image.RGBA, rampLUT *[101][]byte, tier ColorTier) []byte {
|
||||
bounds := img.Bounds()
|
||||
bytesPerCell := 11
|
||||
if tier == ColorTierTrueColor {
|
||||
bytesPerCell = 16
|
||||
}
|
||||
buf := getOutBuf(bounds.Dx() * bounds.Dy() * bytesPerCell)
|
||||
var lastR, lastG, lastB uint8
|
||||
last256 := -1
|
||||
first := true
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
o := img.PixOffset(bounds.Min.X, y)
|
||||
for x := bounds.Min.X; x < bounds.Max.X; x++ {
|
||||
r, g, bl := img.Pix[o], img.Pix[o+1], img.Pix[o+2]
|
||||
brightness := (int(r)*299 + int(g)*587 + int(bl)*114) / 255 / 10
|
||||
colorChanged := first || r != lastR || g != lastG || bl != lastB
|
||||
if tier == ColorTier256 {
|
||||
index := quantize256(r, g, bl)
|
||||
colorChanged = first || index != last256
|
||||
last256 = index
|
||||
}
|
||||
if colorChanged {
|
||||
buf = appendColor(buf, r, g, bl, tier)
|
||||
lastR, lastG, lastB = r, g, bl
|
||||
first = false
|
||||
}
|
||||
buf = append(buf, rampLUT[brightness]...)
|
||||
o += 4
|
||||
}
|
||||
if y < bounds.Max.Y-1 {
|
||||
buf = append(buf, "\r\n"...)
|
||||
}
|
||||
}
|
||||
buf = append(buf, ansiReset...)
|
||||
output := bytes.Clone(buf)
|
||||
putOutBuf(buf)
|
||||
return output
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/YuzuZensai/TrollSSH/internal/tsf"
|
||||
)
|
||||
|
||||
func loadBenchSet(b *testing.B) *tsf.FramesContainer {
|
||||
b.Helper()
|
||||
matches, _ := filepath.Glob("../../frames/*.tsf")
|
||||
if len(matches) == 0 {
|
||||
b.Skip("no .tsf frame set in ../../frames")
|
||||
}
|
||||
fc, err := tsf.Load(matches[0])
|
||||
if err != nil {
|
||||
b.Skip("failed to load frame set:", err)
|
||||
}
|
||||
b.Cleanup(func() { _ = fc.Close() })
|
||||
return fc
|
||||
}
|
||||
|
||||
func benchRender(b *testing.B, tier ColorTier, w, h int) {
|
||||
fc := loadBenchSet(b)
|
||||
r := NewRenderer(0, fc.ColorFrames, Options{
|
||||
BrightnessThreshold: 40,
|
||||
Charset: "detailed",
|
||||
}, nil)
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := r.Render(i%len(fc.ColorFrames), w, h, false, tier); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkRenderTrueColor(b *testing.B) { benchRender(b, ColorTierTrueColor, 120, 40) }
|
||||
func BenchmarkRender256(b *testing.B) { benchRender(b, ColorTier256, 120, 40) }
|
||||
func BenchmarkRenderGray(b *testing.B) { benchRender(b, ColorTierNone, 120, 40) }
|
||||
func BenchmarkRenderTrueBig(b *testing.B) { benchRender(b, ColorTierTrueColor, 240, 70) }
|
||||
|
||||
func BenchmarkRenderCachedParallel(b *testing.B) {
|
||||
fc := loadBenchSet(b)
|
||||
r := NewRenderer(0, fc.ColorFrames, Options{
|
||||
BrightnessThreshold: 40,
|
||||
Charset: "detailed",
|
||||
}, NewCache(8<<20))
|
||||
frame, err := r.Render(0, 120, 40, false, ColorTierTrueColor)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
b.SetBytes(int64(len(frame)))
|
||||
b.ReportAllocs()
|
||||
var failures atomic.Uint64
|
||||
b.ResetTimer()
|
||||
b.RunParallel(func(pb *testing.PB) {
|
||||
for pb.Next() {
|
||||
if _, err := r.Render(0, 120, 40, false, ColorTierTrueColor); err != nil {
|
||||
failures.Add(1)
|
||||
}
|
||||
}
|
||||
})
|
||||
if failures.Load() != 0 {
|
||||
b.Fatalf("render failures: %d", failures.Load())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package render
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestResolveCharset(t *testing.T) {
|
||||
if got := resolveCharset("blocks"); got != " ░▒▓█" {
|
||||
t.Errorf("blocks preset = %q", got)
|
||||
}
|
||||
if got := resolveCharset("XYZ"); got != "XYZ" {
|
||||
t.Errorf("custom ramp = %q", got)
|
||||
}
|
||||
if got := resolveCharset(""); !strings.HasPrefix(got, " .") {
|
||||
t.Errorf("default = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameToAscii(t *testing.T) {
|
||||
// Below threshold -> first ramp char; full brightness -> last.
|
||||
opts := Options{BrightnessThreshold: 40, Charset: "standard"}
|
||||
ramp := []rune(resolveCharset("standard"))
|
||||
img := &image.RGBA{
|
||||
Pix: []byte{0, 0, 0, 255, 255, 255, 255, 255},
|
||||
Stride: 8, Rect: image.Rect(0, 0, 2, 1),
|
||||
}
|
||||
out := []rune(string(frameToAscii(img, buildRampLUT(ramp, opts))))
|
||||
if out[0] != ramp[0] {
|
||||
t.Errorf("dark px = %q, want %q", out[0], ramp[0])
|
||||
}
|
||||
if out[1] != ramp[len(ramp)-1] {
|
||||
t.Errorf("bright px = %q, want %q", out[1], ramp[len(ramp)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrameToAsciiInvert(t *testing.T) {
|
||||
opts := Options{BrightnessThreshold: 40, Charset: "standard", Invert: true}
|
||||
ramp := []rune(resolveCharset("standard"))
|
||||
img := &image.RGBA{
|
||||
Pix: []byte{255, 255, 255, 255},
|
||||
Stride: 4, Rect: image.Rect(0, 0, 1, 1),
|
||||
}
|
||||
out := []rune(string(frameToAscii(img, buildRampLUT(ramp, opts))))
|
||||
if out[0] != ramp[0] {
|
||||
t.Errorf("inverted bright = %q, want %q", out[0], ramp[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderConcurrentSameKey(t *testing.T) {
|
||||
var jpegBuf bytes.Buffer
|
||||
src := image.NewRGBA(image.Rect(0, 0, 16, 16))
|
||||
for i := range src.Pix {
|
||||
src.Pix[i] = byte(i * 7)
|
||||
}
|
||||
if err := jpeg.Encode(&jpegBuf, src, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := NewRenderer(0, [][]byte{jpegBuf.Bytes()}, Options{
|
||||
BrightnessThreshold: 40,
|
||||
Charset: "standard",
|
||||
}, NewCache(1<<20))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([][]byte, 32)
|
||||
for i := range results {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
ascii, err := r.Render(0, 20, 10, false, ColorTierTrueColor)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
results[i] = ascii
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
for i, got := range results {
|
||||
if !bytes.Equal(got, results[0]) {
|
||||
t.Fatalf("result %d differs from result 0", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCacheEvictsByBytes(t *testing.T) {
|
||||
key := func(index int) cacheKey { return cacheKey{index: index} }
|
||||
budget := 3 * entryCost(key(0), bytes.Repeat([]byte("x"), 1000))
|
||||
c := NewCache(budget)
|
||||
for i := range 5 {
|
||||
c.put(key(i), bytes.Repeat([]byte("x"), 1000))
|
||||
}
|
||||
if c.size.Load() > budget {
|
||||
t.Errorf("size %d exceeds budget %d", c.size.Load(), budget)
|
||||
}
|
||||
if _, ok := c.get(key(0)); ok {
|
||||
t.Error("oldest entry should have been evicted")
|
||||
}
|
||||
if _, ok := c.get(key(4)); !ok {
|
||||
t.Error("newest entry should be cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCacheDisabled(t *testing.T) {
|
||||
c := NewCache(0)
|
||||
if c != nil {
|
||||
t.Fatal("zero budget should disable the cache")
|
||||
}
|
||||
c.put(cacheKey{}, []byte("v"))
|
||||
if _, ok := c.get(cacheKey{}); ok {
|
||||
t.Error("nil cache should never hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCacheRejectsOversizedEntry(t *testing.T) {
|
||||
c := NewCache(256)
|
||||
c.put(cacheKey{}, bytes.Repeat([]byte("x"), 10_000))
|
||||
if _, ok := c.get(cacheKey{}); ok {
|
||||
t.Error("entry larger than budget should not be cached")
|
||||
}
|
||||
if c.size.Load() != 0 {
|
||||
t.Errorf("size = %d, want 0", c.size.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCacheAccountsRetainedCapacity(t *testing.T) {
|
||||
cache := NewCache(512)
|
||||
value := make([]byte, 1, 4096)
|
||||
cache.put(cacheKey{}, value)
|
||||
if _, ok := cache.get(cacheKey{}); ok {
|
||||
t.Fatal("cache accepted an entry whose backing allocation exceeds its budget")
|
||||
}
|
||||
if cache.Stats().Rejections != 1 {
|
||||
t.Fatalf("rejections = %d, want 1", cache.Stats().Rejections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnsi256CoalescesQuantizedColors(t *testing.T) {
|
||||
img := &image.RGBA{
|
||||
Pix: []byte{96, 96, 96, 255, 100, 100, 100, 255},
|
||||
Stride: 8,
|
||||
Rect: image.Rect(0, 0, 2, 1),
|
||||
}
|
||||
output := frameToAnsi(img, buildRampLUT([]rune(" .#"), Options{}), ColorTier256)
|
||||
if count := bytes.Count(output, []byte("\x1b[38;5;")); count != 1 {
|
||||
t.Fatalf("color escape count = %d, want 1: %q", count, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnsiDoesNotResetEachRow(t *testing.T) {
|
||||
img := &image.RGBA{
|
||||
Pix: []byte{100, 100, 100, 255, 100, 100, 100, 255},
|
||||
Stride: 4,
|
||||
Rect: image.Rect(0, 0, 1, 2),
|
||||
}
|
||||
output := frameToAnsi(img, buildRampLUT([]rune(" .#"), Options{}), ColorTierTrueColor)
|
||||
if count := bytes.Count(output, []byte(ansiReset)); count != 1 {
|
||||
t.Fatalf("reset count = %d, want 1: %q", count, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectColorTier(t *testing.T) {
|
||||
cases := map[string]ColorTier{
|
||||
"": ColorTierNone,
|
||||
"dumb": ColorTierNone,
|
||||
"vt100": ColorTierNone,
|
||||
"linux": ColorTierNone,
|
||||
"xterm": ColorTierTrueColor,
|
||||
"xterm-256color": ColorTier256,
|
||||
"screen-256color": ColorTier256,
|
||||
"tmux-256color": ColorTier256,
|
||||
"xterm-direct": ColorTierTrueColor,
|
||||
"xterm-kitty": ColorTierTrueColor,
|
||||
}
|
||||
for term, want := range cases {
|
||||
if got := DetectColorTier(term); got != want {
|
||||
t.Errorf("DetectColorTier(%q) = %d, want %d", term, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuantize256(t *testing.T) {
|
||||
if got := quantize256(0, 0, 0); got != 16 {
|
||||
t.Errorf("black = %d, want 16", got)
|
||||
}
|
||||
if got := quantize256(255, 255, 255); got != 231 {
|
||||
t.Errorf("white = %d, want 231", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user