Files
TrollSSH/src/frames.go
T

375 lines
8.2 KiB
Go
Raw Normal View History

2026-07-13 17:47:00 +07:00
package main
import (
"bytes"
"container/list"
"fmt"
"image"
"image/color"
"image/jpeg"
2026-07-14 05:49:10 +07:00
"strconv"
2026-07-13 17:47:00 +07:00
"strings"
"sync"
2026-07-14 05:49:10 +07:00
"unicode/utf8"
2026-07-13 17:47:00 +07:00
"golang.org/x/image/draw"
)
type FramesContainer struct {
ColorFrames [][]byte
FPS float64
Name string
}
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
2026-07-13 17:47:00 +07:00
}
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
}
2026-07-14 05:49:10 +07:00
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) {
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) {
outPool.Put(&b)
}
2026-07-14 13:28:47 +07:00
func resizeFrame(frame, pix []byte, width, height int, keepAspectRatio bool) (*image.RGBA, error) {
2026-07-13 17:47:00 +07:00
src, err := jpeg.Decode(bytes.NewReader(frame))
if err != nil {
return nil, err
}
2026-07-14 05:49:10 +07:00
rect := image.Rect(0, 0, width, height)
2026-07-14 13:28:47 +07:00
dst := &image.RGBA{Pix: pix[:4*width*height], Stride: 4 * width, Rect: rect}
2026-07-13 17:47:00 +07:00
if keepAspectRatio {
2026-07-14 13:28:47 +07:00
draw.Draw(dst, dst.Bounds(), image.NewUniform(color.Black), image.Point{}, draw.Src)
2026-07-13 17:47:00 +07:00
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
2026-07-13 17:47:00 +07:00
}
type asciiOptions struct {
brightnessThreshold int
charset string
invert bool
}
2026-07-14 13:28:47 +07:00
func buildRampLUT(ramp []rune, options asciiOptions) *[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
}
2026-07-14 13:28:47 +07:00
func frameToAscii(img *image.RGBA, rampLUT *[101][]byte) string {
pix := img.Pix
buf := getOutBuf(len(pix))
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]...)
2026-07-13 17:47:00 +07:00
}
2026-07-14 05:49:10 +07:00
ascii := string(buf)
putOutBuf(buf)
return ascii
2026-07-13 17:47:00 +07:00
}
const ansiReset = "\x1b[0m"
var ansi256Levels = [6]int{0, 95, 135, 175, 215, 255}
2026-07-14 13:28:47 +07:00
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 {
2026-07-14 13:28:47 +07:00
d := v - l
if d < 0 {
d = -d
}
if d < bestDist {
bestDist, best = d, i
}
}
2026-07-14 13:28:47 +07:00
t[v] = uint8(best)
}
2026-07-14 13:28:47 +07:00
return
}()
func quantize256(r, g, b uint8) int {
return 16 + 36*int(ansi256Cube[r]) + 6*int(ansi256Cube[g]) + int(ansi256Cube[b])
}
2026-07-14 05:49:10 +07:00
func appendColor(buf []byte, r, g, b uint8, tier colorTier) []byte {
if tier == colorTierTrueColor {
buf = append(buf, "\x1b[38;2;"...)
2026-07-14 13:28:47 +07:00
buf = append(buf, decimal[r]...)
2026-07-14 05:49:10 +07:00
buf = append(buf, ';')
2026-07-14 13:28:47 +07:00
buf = append(buf, decimal[g]...)
2026-07-14 05:49:10 +07:00
buf = append(buf, ';')
2026-07-14 13:28:47 +07:00
buf = append(buf, decimal[b]...)
2026-07-14 05:49:10 +07:00
} else {
buf = append(buf, "\x1b[38;5;"...)
2026-07-14 13:28:47 +07:00
buf = append(buf, decimal[quantize256(r, g, b)]...)
2026-07-14 05:49:10 +07:00
}
return append(buf, 'm')
}
2026-07-14 13:28:47 +07:00
func frameToAnsi(img *image.RGBA, rampLUT *[101][]byte, tier colorTier) string {
bounds := img.Bounds()
2026-07-14 05:49:10 +07:00
buf := getOutBuf(bounds.Dx() * bounds.Dy() * 16)
var lastR, lastG, lastB uint8
first := true
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
for x := bounds.Min.X; x < bounds.Max.X; x++ {
o := img.PixOffset(x, y)
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
if first || r != lastR || g != lastG || bl != lastB {
2026-07-14 05:49:10 +07:00
buf = appendColor(buf, r, g, bl, tier)
lastR, lastG, lastB = r, g, bl
first = false
}
2026-07-14 13:28:47 +07:00
buf = append(buf, rampLUT[brightness]...)
}
if y < bounds.Max.Y-1 {
2026-07-14 05:49:10 +07:00
buf = append(buf, ansiReset+"\r\n"...)
first = true
}
}
2026-07-14 05:49:10 +07:00
buf = append(buf, ansiReset...)
ascii := string(buf)
putOutBuf(buf)
return ascii
}
2026-07-14 05:49:10 +07:00
type renderCache struct {
mu sync.Mutex
maxBytes int64
size int64
entries map[string]*list.Element
order *list.List
2026-07-13 17:47:00 +07:00
}
type cacheEntry struct {
key string
ascii string
}
2026-07-14 05:49:10 +07:00
func entryCost(key, ascii string) int64 {
return int64(len(key)+len(ascii)) + 128
}
func newRenderCache(maxBytes int64) *renderCache {
if maxBytes <= 0 {
return nil
}
return &renderCache{
maxBytes: maxBytes,
entries: make(map[string]*list.Element),
order: list.New(),
}
}
func (c *renderCache) get(key string) (string, bool) {
if c == nil {
return "", false
}
c.mu.Lock()
defer c.mu.Unlock()
el, ok := c.entries[key]
if !ok {
return "", false
}
c.order.MoveToBack(el)
return el.Value.(*cacheEntry).ascii, true
}
func (c *renderCache) put(key, ascii string) {
if c == nil {
return
}
cost := entryCost(key, ascii)
if cost > c.maxBytes {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if _, ok := c.entries[key]; ok {
return
}
c.entries[key] = c.order.PushBack(&cacheEntry{key, ascii})
c.size += cost
for c.size > c.maxBytes {
oldest := c.order.Front()
c.order.Remove(oldest)
evicted := oldest.Value.(*cacheEntry)
delete(c.entries, evicted.key)
c.size -= entryCost(evicted.key, evicted.ascii)
}
}
type FrameRenderer struct {
setID int
colorFrames [][]byte
options asciiOptions
2026-07-14 13:28:47 +07:00
rampLUT *[101][]byte
2026-07-14 05:49:10 +07:00
cache *renderCache
2026-07-14 13:28:47 +07:00
inflightMu sync.Mutex
inflight map[string]chan struct{}
2026-07-14 05:49:10 +07:00
}
func newFrameRenderer(setID int, colorFrames [][]byte, options asciiOptions, cache *renderCache) *FrameRenderer {
2026-07-14 13:28:47 +07:00
ramp := []rune(resolveCharset(options.charset))
2026-07-13 17:47:00 +07:00
return &FrameRenderer{
2026-07-14 05:49:10 +07:00
setID: setID,
colorFrames: colorFrames,
options: options,
2026-07-14 13:28:47 +07:00
rampLUT: buildRampLUT(ramp, options),
2026-07-14 05:49:10 +07:00
cache: cache,
2026-07-14 13:28:47 +07:00
inflight: make(map[string]chan struct{}),
2026-07-13 17:47:00 +07:00
}
}
func (r *FrameRenderer) render(index, width, height int, keepAspectRatio bool, tier colorTier) (string, error) {
2026-07-14 05:49:10 +07:00
key := fmt.Sprintf("%d:%d:%dx%d:%t:%d", r.setID, index, width, height, keepAspectRatio, tier)
if ascii, ok := r.cache.get(key); ok {
2026-07-13 17:47:00 +07:00
return ascii, nil
}
2026-07-14 13:28:47 +07:00
if r.cache != nil {
for {
r.inflightMu.Lock()
wait, ok := r.inflight[key]
if !ok {
done := make(chan struct{})
r.inflight[key] = done
r.inflightMu.Unlock()
defer func() {
r.inflightMu.Lock()
delete(r.inflight, key)
r.inflightMu.Unlock()
close(done)
}()
break
}
r.inflightMu.Unlock()
<-wait
if ascii, ok := r.cache.get(key); ok {
return ascii, nil
}
}
2026-07-14 05:49:10 +07:00
}
2026-07-14 13:28:47 +07:00
pix := getPixBuf(4 * width * height)
img, err := resizeFrame(r.colorFrames[index], pix, width, height, keepAspectRatio)
2026-07-14 05:49:10 +07:00
if err != nil {
putPixBuf(pix)
return "", err
}
var ascii string
if tier == colorTierNone {
2026-07-14 13:28:47 +07:00
ascii = frameToAscii(img, r.rampLUT)
} else {
2026-07-14 13:28:47 +07:00
ascii = frameToAnsi(img, r.rampLUT, tier)
2026-07-13 17:47:00 +07:00
}
2026-07-14 05:49:10 +07:00
putPixBuf(pix)
2026-07-13 17:47:00 +07:00
2026-07-14 05:49:10 +07:00
r.cache.put(key, ascii)
2026-07-13 17:47:00 +07:00
return ascii, nil
}