mirror of
https://github.com/YuzuZensai/TrollSSH.git
synced 2026-09-13 19:49:04 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7e831f373
|
||
|
|
dbf318a20e
|
||
|
|
2c533d29f7
|
||
|
|
267b6a89b8
|
||
|
|
286c28bfcf
|
+8
-7
@@ -1,12 +1,7 @@
|
||||
HOST=0.0.0.0
|
||||
PORT=22
|
||||
|
||||
# Generation settings
|
||||
# Stored frame resolution in pixels. Higher = sharper but bigger .tsf files.
|
||||
# regenerate your frames after changing it.
|
||||
FRAME_RESOLUTION=512
|
||||
|
||||
# Playback settings.
|
||||
# Playback settings
|
||||
# Playthroughs before the session is closed (0, unlimited).
|
||||
MAX_LOOP=5
|
||||
# Whether to keep looping the same frame set or pick a random one after each
|
||||
@@ -30,7 +25,13 @@ INVERT=false
|
||||
FORCE_GRAYSCALE=false
|
||||
|
||||
# Max rendered width/height in characters.
|
||||
MAX_DIMENSION=1080
|
||||
MAX_DIMENSION=512
|
||||
|
||||
# Memory budget in MB for the rendered-frame cache (0 disables caching).
|
||||
RENDER_CACHE_MB=256
|
||||
|
||||
# Go soft memory limit; set below your container limit to avoid OOM kills.
|
||||
GOMEMLIMIT=1GiB
|
||||
|
||||
# Connection limits. New connections over a limit are dropped immediately.
|
||||
# Max simultaneous connections from a single client IP.
|
||||
|
||||
@@ -25,7 +25,7 @@ Generate a frame set from a video through container image
|
||||
|
||||
```sh
|
||||
docker run --rm -v ./video.mp4:/home/app/video.mp4 -v ./frames:/home/app/frames \
|
||||
ghcr.io/yuzuzensai/trollssh:latest trollssh --generate --video video.mp4
|
||||
ghcr.io/yuzuzensai/trollssh:v1.0.1 trollssh --generate --video video.mp4 --resolution 512
|
||||
```
|
||||
|
||||
This writes `frames/<name>.tsf`, a simple container of color JPEG frames plus
|
||||
@@ -51,13 +51,20 @@ ssh anyone@localhost
|
||||
|
||||
## Configuration
|
||||
|
||||
Configuration is via environment variables, loaded from a `.env` file if one
|
||||
exists (see [`.env.example`](.env.example) for the full annotated list).
|
||||
Durations are in milliseconds.
|
||||
Server configuration is via environment variables, loaded from a `.env` file
|
||||
if one exists (see [`.env.example`](.env.example) for the full annotated
|
||||
list). Durations are in milliseconds.
|
||||
|
||||
Host keys (`data/id_rsa`, `data/id_ed25519`) are generated on first run and
|
||||
reused afterwards.
|
||||
|
||||
Frame generation is configured with flags:
|
||||
|
||||
| Flag | Default | Description |
|
||||
| -------------------- | ------- | -------------------------------------------------- |
|
||||
| `--generate`, `-g` | | Generate a `.tsf` frame set instead of serving |
|
||||
| `--video`, `-v` | | Source video path |
|
||||
| `--resolution`, `-r` | `512` | Stored frame max dimension in pixels. Higher = sharper but bigger `.tsf` files and slower rendering |
|
||||
|
||||
## Customization
|
||||
|
||||
@@ -75,7 +82,7 @@ Requirements: Go 1.25+ and `ffmpeg` / `ffprobe` on `PATH` (only for
|
||||
`--generate`).
|
||||
|
||||
```sh
|
||||
go run ./src --generate --video video.mp4
|
||||
go run ./src --generate --video video.mp4 --resolution 512
|
||||
go run ./src
|
||||
```
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
trollssh:
|
||||
image: ghcr.io/yuzuzensai/trollssh:latest
|
||||
image: ghcr.io/yuzuzensai/trollssh:v1.0.1
|
||||
container_name: trollssh
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
|
||||
+91
-2
@@ -1,9 +1,14 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -168,7 +173,11 @@ func TestFrameToAscii(t *testing.T) {
|
||||
// Below threshold -> first ramp char; full brightness -> last.
|
||||
opts := asciiOptions{brightnessThreshold: 40, charset: "standard"}
|
||||
ramp := []rune(resolveCharset("standard"))
|
||||
out := []rune(frameToAscii([]byte{0, 255}, opts))
|
||||
img := &image.RGBA{
|
||||
Pix: []byte{0, 0, 0, 255, 255, 255, 255, 255},
|
||||
Stride: 8, Rect: image.Rect(0, 0, 2, 1),
|
||||
}
|
||||
out := []rune(frameToAscii(img, buildRampLUT(ramp, opts)))
|
||||
if out[0] != ramp[0] {
|
||||
t.Errorf("dark px = %q, want %q", out[0], ramp[0])
|
||||
}
|
||||
@@ -180,12 +189,92 @@ func TestFrameToAscii(t *testing.T) {
|
||||
func TestFrameToAsciiInvert(t *testing.T) {
|
||||
opts := asciiOptions{brightnessThreshold: 40, charset: "standard", invert: true}
|
||||
ramp := []rune(resolveCharset("standard"))
|
||||
out := []rune(frameToAscii([]byte{255}, opts))
|
||||
img := &image.RGBA{
|
||||
Pix: []byte{255, 255, 255, 255},
|
||||
Stride: 4, Rect: image.Rect(0, 0, 1, 1),
|
||||
}
|
||||
out := []rune(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 := newFrameRenderer(0, [][]byte{jpegBuf.Bytes()}, asciiOptions{
|
||||
brightnessThreshold: 40,
|
||||
charset: "standard",
|
||||
}, newRenderCache(1<<20))
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]string, 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 got != results[0] {
|
||||
t.Fatalf("result %d differs from result 0", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCacheEvictsByBytes(t *testing.T) {
|
||||
budget := 3 * entryCost("k", strings.Repeat("x", 1000))
|
||||
c := newRenderCache(budget)
|
||||
for i := range 5 {
|
||||
c.put(fmt.Sprintf("%d", i), strings.Repeat("x", 1000))
|
||||
}
|
||||
if c.size > budget {
|
||||
t.Errorf("size %d exceeds budget %d", c.size, budget)
|
||||
}
|
||||
if _, ok := c.get("0"); ok {
|
||||
t.Error("oldest entry should have been evicted")
|
||||
}
|
||||
if _, ok := c.get("4"); !ok {
|
||||
t.Error("newest entry should be cached")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCacheDisabled(t *testing.T) {
|
||||
c := newRenderCache(0)
|
||||
if c != nil {
|
||||
t.Fatal("zero budget should disable the cache")
|
||||
}
|
||||
c.put("k", "v")
|
||||
if _, ok := c.get("k"); ok {
|
||||
t.Error("nil cache should never hit")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderCacheRejectsOversizedEntry(t *testing.T) {
|
||||
c := newRenderCache(256)
|
||||
c.put("big", strings.Repeat("x", 10_000))
|
||||
if _, ok := c.get("big"); ok {
|
||||
t.Error("entry larger than budget should not be cached")
|
||||
}
|
||||
if c.size != 0 {
|
||||
t.Errorf("size = %d, want 0", c.size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnectionTracker(t *testing.T) {
|
||||
tr := newConnectionTracker()
|
||||
tr.increment("1.2.3.4")
|
||||
|
||||
+2
-2
@@ -28,7 +28,7 @@ type Config struct {
|
||||
MaxAuthAttempts int
|
||||
HandshakeTimeout time.Duration
|
||||
MaxDimension int
|
||||
FrameResolution int
|
||||
RenderCacheMB int
|
||||
BrightnessThreshold int
|
||||
Charset string
|
||||
Invert bool
|
||||
@@ -126,7 +126,7 @@ func loadConfig() Config {
|
||||
MaxAuthAttempts: envInt("MAX_AUTH_ATTEMPTS", 6, 1, maxInt),
|
||||
HandshakeTimeout: envDurationMs("HANDSHAKE_TIMEOUT", 10*time.Second),
|
||||
MaxDimension: envInt("MAX_DIMENSION", 512, 1, 4096),
|
||||
FrameResolution: envInt("FRAME_RESOLUTION", 360, 16, 1080),
|
||||
RenderCacheMB: envInt("RENDER_CACHE_MB", 256, 0, maxInt),
|
||||
BrightnessThreshold: envInt("BRIGHTNESS_THRESHOLD", 40, 0, 100),
|
||||
Charset: envString("CHARSET", "detailed"),
|
||||
Invert: envBool("INVERT", false),
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ func writeTSF(output string, data *FramesContainer) error {
|
||||
}
|
||||
|
||||
func loadTSF(filename string) (*FramesContainer, error) {
|
||||
raw, err := os.ReadFile(filename)
|
||||
raw, err := readFrameFile(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
+207
-79
@@ -7,8 +7,10 @@ import (
|
||||
"image"
|
||||
"image/color"
|
||||
"image/jpeg"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode/utf8"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
)
|
||||
@@ -62,22 +64,46 @@ func resolveCharset(charset string) string {
|
||||
return charset
|
||||
}
|
||||
|
||||
func resizeFrame(frame []byte, width, height int, keepAspectRatio bool, tier colorTier) (draw.Image, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
var dst draw.Image
|
||||
var bg color.Color
|
||||
if tier == colorTierNone {
|
||||
dst = image.NewGray(image.Rect(0, 0, width, height))
|
||||
bg = color.Gray{0}
|
||||
} else {
|
||||
dst = image.NewNRGBA(image.Rect(0, 0, width, height))
|
||||
bg = color.Black
|
||||
}
|
||||
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(bg), image.Point{}, draw.Src)
|
||||
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))
|
||||
@@ -98,6 +124,15 @@ type asciiOptions struct {
|
||||
invert bool
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -114,27 +149,34 @@ func rampIndex(brightness, threshold, total int, invert bool) int {
|
||||
return index
|
||||
}
|
||||
|
||||
func frameToAscii(pixels []byte, options asciiOptions) string {
|
||||
ramp := []rune(resolveCharset(options.charset))
|
||||
total := len(ramp)
|
||||
var b strings.Builder
|
||||
for _, p := range pixels {
|
||||
brightness := int(p) * 100 / 255
|
||||
index := rampIndex(brightness, options.brightnessThreshold, total, options.invert)
|
||||
b.WriteRune(ramp[index])
|
||||
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]...)
|
||||
}
|
||||
return b.String()
|
||||
ascii := string(buf)
|
||||
putOutBuf(buf)
|
||||
return ascii
|
||||
}
|
||||
|
||||
const ansiReset = "\x1b[0m"
|
||||
|
||||
var ansi256Levels = [6]int{0, 95, 135, 175, 215, 255}
|
||||
|
||||
func quantize256(r, g, b uint8) int {
|
||||
toLevel := func(v uint8) int {
|
||||
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 := int(v) - l
|
||||
d := v - l
|
||||
if d < 0 {
|
||||
d = -d
|
||||
}
|
||||
@@ -142,16 +184,33 @@ func quantize256(r, g, b uint8) int {
|
||||
bestDist, best = d, i
|
||||
}
|
||||
}
|
||||
return best
|
||||
t[v] = uint8(best)
|
||||
}
|
||||
return 16 + 36*toLevel(r) + 6*toLevel(g) + toLevel(b)
|
||||
return
|
||||
}()
|
||||
|
||||
func quantize256(r, g, b uint8) int {
|
||||
return 16 + 36*int(ansi256Cube[r]) + 6*int(ansi256Cube[g]) + int(ansi256Cube[b])
|
||||
}
|
||||
|
||||
func frameToAnsi(img *image.NRGBA, options asciiOptions, tier colorTier) string {
|
||||
ramp := []rune(resolveCharset(options.charset))
|
||||
total := len(ramp)
|
||||
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) string {
|
||||
bounds := img.Bounds()
|
||||
var b strings.Builder
|
||||
buf := getOutBuf(bounds.Dx() * bounds.Dy() * 16)
|
||||
var lastR, lastG, lastB uint8
|
||||
first := true
|
||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||
@@ -159,35 +218,30 @@ func frameToAnsi(img *image.NRGBA, options asciiOptions, tier colorTier) string
|
||||
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
|
||||
index := rampIndex(brightness, options.brightnessThreshold, total, options.invert)
|
||||
if first || r != lastR || g != lastG || bl != lastB {
|
||||
if tier == colorTierTrueColor {
|
||||
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm", r, g, bl)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "\x1b[38;5;%dm", quantize256(r, g, bl))
|
||||
}
|
||||
buf = appendColor(buf, r, g, bl, tier)
|
||||
lastR, lastG, lastB = r, g, bl
|
||||
first = false
|
||||
}
|
||||
b.WriteRune(ramp[index])
|
||||
buf = append(buf, rampLUT[brightness]...)
|
||||
}
|
||||
if y < bounds.Max.Y-1 {
|
||||
b.WriteString(ansiReset + "\r\n")
|
||||
buf = append(buf, ansiReset+"\r\n"...)
|
||||
first = true
|
||||
}
|
||||
}
|
||||
b.WriteString(ansiReset)
|
||||
return b.String()
|
||||
buf = append(buf, ansiReset...)
|
||||
ascii := string(buf)
|
||||
putOutBuf(buf)
|
||||
return ascii
|
||||
}
|
||||
|
||||
type FrameRenderer struct {
|
||||
colorFrames [][]byte
|
||||
options asciiOptions
|
||||
maxEntries int
|
||||
|
||||
mu sync.Mutex
|
||||
cache map[string]*list.Element
|
||||
order *list.List
|
||||
type renderCache struct {
|
||||
mu sync.Mutex
|
||||
maxBytes int64
|
||||
size int64
|
||||
entries map[string]*list.Element
|
||||
order *list.List
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
@@ -195,52 +249,126 @@ type cacheEntry struct {
|
||||
ascii string
|
||||
}
|
||||
|
||||
func newFrameRenderer(colorFrames [][]byte, options asciiOptions) *FrameRenderer {
|
||||
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
|
||||
rampLUT *[101][]byte
|
||||
cache *renderCache
|
||||
|
||||
inflightMu sync.Mutex
|
||||
inflight map[string]chan struct{}
|
||||
}
|
||||
|
||||
func newFrameRenderer(setID int, colorFrames [][]byte, options asciiOptions, cache *renderCache) *FrameRenderer {
|
||||
ramp := []rune(resolveCharset(options.charset))
|
||||
return &FrameRenderer{
|
||||
setID: setID,
|
||||
colorFrames: colorFrames,
|
||||
options: options,
|
||||
maxEntries: 4096,
|
||||
cache: make(map[string]*list.Element),
|
||||
order: list.New(),
|
||||
rampLUT: buildRampLUT(ramp, options),
|
||||
cache: cache,
|
||||
inflight: make(map[string]chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *FrameRenderer) render(index, width, height int, keepAspectRatio bool, tier colorTier) (string, error) {
|
||||
key := fmt.Sprintf("%d:%dx%d:%t:%d", index, width, height, keepAspectRatio, tier)
|
||||
|
||||
r.mu.Lock()
|
||||
if el, ok := r.cache[key]; ok {
|
||||
r.order.MoveToBack(el)
|
||||
ascii := el.Value.(*cacheEntry).ascii
|
||||
r.mu.Unlock()
|
||||
key := fmt.Sprintf("%d:%d:%dx%d:%t:%d", r.setID, index, width, height, keepAspectRatio, tier)
|
||||
if ascii, ok := r.cache.get(key); ok {
|
||||
return ascii, nil
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pix := getPixBuf(4 * width * height)
|
||||
img, err := resizeFrame(r.colorFrames[index], pix, width, height, keepAspectRatio)
|
||||
if err != nil {
|
||||
putPixBuf(pix)
|
||||
return "", err
|
||||
}
|
||||
var ascii string
|
||||
if tier == colorTierNone {
|
||||
img, err := resizeFrame(r.colorFrames[index], width, height, keepAspectRatio, tier)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ascii = frameToAscii(img.(*image.Gray).Pix, r.options)
|
||||
ascii = frameToAscii(img, r.rampLUT)
|
||||
} else {
|
||||
img, err := resizeFrame(r.colorFrames[index], width, height, keepAspectRatio, tier)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ascii = frameToAnsi(img.(*image.NRGBA), r.options, tier)
|
||||
ascii = frameToAnsi(img, r.rampLUT, tier)
|
||||
}
|
||||
putPixBuf(pix)
|
||||
|
||||
r.mu.Lock()
|
||||
if _, ok := r.cache[key]; !ok {
|
||||
r.cache[key] = r.order.PushBack(&cacheEntry{key, ascii})
|
||||
if r.order.Len() > r.maxEntries {
|
||||
oldest := r.order.Front()
|
||||
r.order.Remove(oldest)
|
||||
delete(r.cache, oldest.Value.(*cacheEntry).key)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
r.cache.put(key, ascii)
|
||||
return ascii, nil
|
||||
}
|
||||
|
||||
+40
-6
@@ -6,7 +6,9 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -16,12 +18,13 @@ import (
|
||||
)
|
||||
|
||||
type cliArgs struct {
|
||||
generate bool
|
||||
video string
|
||||
generate bool
|
||||
video string
|
||||
resolution int
|
||||
}
|
||||
|
||||
func parseArgs(argv []string) cliArgs {
|
||||
var args cliArgs
|
||||
args := cliArgs{resolution: 512}
|
||||
for i := 0; i < len(argv); i++ {
|
||||
switch argv[i] {
|
||||
case "--generate", "-g":
|
||||
@@ -31,6 +34,13 @@ func parseArgs(argv []string) cliArgs {
|
||||
i++
|
||||
args.video = argv[i]
|
||||
}
|
||||
case "--resolution", "-r":
|
||||
if i+1 < len(argv) {
|
||||
i++
|
||||
if n, err := strconv.Atoi(argv[i]); err == nil {
|
||||
args.resolution = max(n, 16)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return args
|
||||
@@ -52,7 +62,7 @@ func resolveVideoPath(explicitPath string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func generateFrames(config Config, framesDir, videoArg string) {
|
||||
func generateFrames(framesDir, videoArg string, resolution int) {
|
||||
if videoArg == "" {
|
||||
fail("No source video given. Pass --video <path>.")
|
||||
}
|
||||
@@ -68,7 +78,7 @@ func generateFrames(config Config, framesDir, videoArg string) {
|
||||
output := filepath.Join(framesDir, base+".tsf")
|
||||
|
||||
logInfo(fmt.Sprintf("Generating frames from %q -> %s", videoPath, output))
|
||||
if err := processVideo(videoPath, output, config.FrameResolution); err != nil {
|
||||
if err := processVideo(videoPath, output, resolution); err != nil {
|
||||
fail(fmt.Sprintf("Failed to generate frames from %q: %s", videoPath, err.Error()))
|
||||
}
|
||||
}
|
||||
@@ -143,9 +153,33 @@ func loadAllFrames(framesDir string) []*FramesContainer {
|
||||
return results
|
||||
}
|
||||
|
||||
func applyMemoryLimit() {
|
||||
if os.Getenv("GOMEMLIMIT") != "" {
|
||||
return
|
||||
}
|
||||
for _, path := range []string{
|
||||
"/sys/fs/cgroup/memory.max",
|
||||
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
|
||||
} {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(string(raw)), 10, 64)
|
||||
if err != nil || n <= 0 || n > 1<<48 {
|
||||
return
|
||||
}
|
||||
limit := n * 9 / 10
|
||||
debug.SetMemoryLimit(limit)
|
||||
logInfo(fmt.Sprintf("Memory limit set to %d MB (90%% of cgroup limit)", limit>>20))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
_ = godotenv.Load()
|
||||
logThreshold = resolveThreshold()
|
||||
applyMemoryLimit()
|
||||
|
||||
config := loadConfig()
|
||||
args := parseArgs(os.Args[1:])
|
||||
@@ -158,7 +192,7 @@ func main() {
|
||||
framesDir := filepath.Join(cwd, "frames")
|
||||
|
||||
if args.generate {
|
||||
generateFrames(config, framesDir, args.video)
|
||||
generateFrames(framesDir, args.video, args.resolution)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build !unix
|
||||
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
func readFrameFile(filename string) ([]byte, error) {
|
||||
return os.ReadFile(filename)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
//go:build unix
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
func readFrameFile(filename string) ([]byte, error) {
|
||||
f, err := os.Open(filename)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
size := info.Size()
|
||||
if size <= 0 || size != int64(int(size)) {
|
||||
return os.ReadFile(filename)
|
||||
}
|
||||
data, err := syscall.Mmap(int(f.Fd()), 0, int(size), syscall.PROT_READ, syscall.MAP_SHARED)
|
||||
if err != nil {
|
||||
return os.ReadFile(filename)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func loadBenchSet(b *testing.B) *FramesContainer {
|
||||
b.Helper()
|
||||
matches, _ := filepath.Glob("../frames/*.tsf")
|
||||
if len(matches) == 0 {
|
||||
b.Skip("no .tsf frame set in ../frames")
|
||||
}
|
||||
fc, err := loadTSF(matches[0])
|
||||
if err != nil {
|
||||
b.Skip("failed to load frame set:", err)
|
||||
}
|
||||
return fc
|
||||
}
|
||||
|
||||
func benchRender(b *testing.B, tier colorTier, w, h int) {
|
||||
fc := loadBenchSet(b)
|
||||
r := newFrameRenderer(0, fc.ColorFrames, asciiOptions{
|
||||
brightnessThreshold: 40,
|
||||
charset: "detailed",
|
||||
}, nil)
|
||||
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) }
|
||||
+29
-5
@@ -13,7 +13,14 @@ import (
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const clearScreen = "\x1b[2J\x1b[0f"
|
||||
const (
|
||||
clearScreen = "\x1b[2J\x1b[0f"
|
||||
hideCursor = "\x1b[?25l"
|
||||
showCursor = "\x1b[?25h"
|
||||
syncStart = "\x1b[?2026h"
|
||||
syncEnd = "\x1b[?2026l"
|
||||
homeCursor = "\x1b[H"
|
||||
)
|
||||
|
||||
type ConnectionTracker struct {
|
||||
mu sync.Mutex
|
||||
@@ -98,15 +105,16 @@ func clampDimension(value, max int) int {
|
||||
func createServer(deps ServerDeps) *Server {
|
||||
config := deps.Config
|
||||
|
||||
cache := newRenderCache(int64(config.RenderCacheMB) << 20)
|
||||
sets := make([]frameSet, len(deps.VideoSets))
|
||||
for i, data := range deps.VideoSets {
|
||||
sets[i] = frameSet{
|
||||
data: data,
|
||||
renderer: newFrameRenderer(data.ColorFrames, asciiOptions{
|
||||
renderer: newFrameRenderer(i, data.ColorFrames, asciiOptions{
|
||||
brightnessThreshold: config.BrightnessThreshold,
|
||||
charset: config.Charset,
|
||||
invert: config.Invert,
|
||||
}),
|
||||
}, cache),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -383,6 +391,8 @@ func (s *Server) playVideo(
|
||||
w, h := size.get()
|
||||
logDebug(fmt.Sprintf("Terminal size %dx%d for %s", w, h, ip))
|
||||
|
||||
defer func() { _, _ = channel.Write([]byte(showCursor)) }()
|
||||
|
||||
if s.fakeLogin != nil {
|
||||
_, _ = channel.Write([]byte(clearScreen))
|
||||
_, _ = channel.Write([]byte(*s.fakeLogin))
|
||||
@@ -435,6 +445,8 @@ func (s *Server) playVideo(
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = channel.Write([]byte(hideCursor))
|
||||
|
||||
frameInterval := func() time.Duration {
|
||||
return time.Duration(float64(time.Second) / current.data.FPS)
|
||||
}
|
||||
@@ -444,6 +456,8 @@ func (s *Server) playVideo(
|
||||
|
||||
currentFrame := 0
|
||||
loopCount := 0
|
||||
lastW, lastH := 0, 0
|
||||
var writeBuf []byte
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -457,6 +471,7 @@ func (s *Server) playVideo(
|
||||
setIndex = (setIndex + delta + len(s.sets)) % len(s.sets)
|
||||
current = s.sets[setIndex]
|
||||
currentFrame = 0
|
||||
lastW, lastH = 0, 0
|
||||
logDebug(fmt.Sprintf("%s switched to %q", ip, current.data.Name))
|
||||
ticker.Reset(frameInterval())
|
||||
|
||||
@@ -469,7 +484,16 @@ func (s *Server) playVideo(
|
||||
return
|
||||
}
|
||||
|
||||
if _, err := channel.Write([]byte(clearScreen + ascii)); err != nil {
|
||||
prefix := homeCursor
|
||||
if w != lastW || h != lastH {
|
||||
prefix = clearScreen
|
||||
lastW, lastH = w, h
|
||||
}
|
||||
writeBuf = append(writeBuf[:0], syncStart...)
|
||||
writeBuf = append(writeBuf, prefix...)
|
||||
writeBuf = append(writeBuf, ascii...)
|
||||
writeBuf = append(writeBuf, syncEnd...)
|
||||
if _, err := channel.Write(writeBuf); err != nil {
|
||||
closeSession()
|
||||
return
|
||||
}
|
||||
@@ -482,7 +506,7 @@ func (s *Server) playVideo(
|
||||
currentFrame = 0
|
||||
loopCount++
|
||||
if config.MaxLoop > 0 && loopCount >= config.MaxLoop {
|
||||
_, _ = channel.Write([]byte(clearScreen))
|
||||
_, _ = channel.Write([]byte(showCursor + clearScreen))
|
||||
if s.goodbye != nil {
|
||||
_, _ = channel.Write([]byte(*s.goodbye))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user