♻️ refactor: split src into internal packages and cmd

This commit is contained in:
2026-07-16 23:51:39 +07:00
parent 2e320b03f3
commit a05d6bb7eb
28 changed files with 1355 additions and 1328 deletions
+232
View File
@@ -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
}
+250
View File
@@ -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
}
+69
View File
@@ -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())
}
}
+194
View File
@@ -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)
}
}