mirror of
https://github.com/YuzuZensai/TrollSSH.git
synced 2026-09-13 20:19:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbf318a20e
|
||
|
|
2c533d29f7
|
||
|
|
267b6a89b8
|
||
|
|
286c28bfcf
|
+8
-7
@@ -1,12 +1,7 @@
|
|||||||
HOST=0.0.0.0
|
HOST=0.0.0.0
|
||||||
PORT=22
|
PORT=22
|
||||||
|
|
||||||
# Generation settings
|
# Playback settings
|
||||||
# Stored frame resolution in pixels. Higher = sharper but bigger .tsf files.
|
|
||||||
# regenerate your frames after changing it.
|
|
||||||
FRAME_RESOLUTION=512
|
|
||||||
|
|
||||||
# Playback settings.
|
|
||||||
# Playthroughs before the session is closed (0, unlimited).
|
# Playthroughs before the session is closed (0, unlimited).
|
||||||
MAX_LOOP=5
|
MAX_LOOP=5
|
||||||
# Whether to keep looping the same frame set or pick a random one after each
|
# Whether to keep looping the same frame set or pick a random one after each
|
||||||
@@ -30,7 +25,13 @@ INVERT=false
|
|||||||
FORCE_GRAYSCALE=false
|
FORCE_GRAYSCALE=false
|
||||||
|
|
||||||
# Max rendered width/height in characters.
|
# 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.
|
# Connection limits. New connections over a limit are dropped immediately.
|
||||||
# Max simultaneous connections from a single client IP.
|
# Max simultaneous connections from a single client IP.
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ Generate a frame set from a video through container image
|
|||||||
|
|
||||||
```sh
|
```sh
|
||||||
docker run --rm -v ./video.mp4:/home/app/video.mp4 -v ./frames:/home/app/frames \
|
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
|
This writes `frames/<name>.tsf`, a simple container of color JPEG frames plus
|
||||||
@@ -51,13 +51,20 @@ ssh anyone@localhost
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
Configuration is via environment variables, loaded from a `.env` file if one
|
Server configuration is via environment variables, loaded from a `.env` file
|
||||||
exists (see [`.env.example`](.env.example) for the full annotated list).
|
if one exists (see [`.env.example`](.env.example) for the full annotated
|
||||||
Durations are in milliseconds.
|
list). Durations are in milliseconds.
|
||||||
|
|
||||||
Host keys (`data/id_rsa`, `data/id_ed25519`) are generated on first run and
|
Host keys (`data/id_rsa`, `data/id_ed25519`) are generated on first run and
|
||||||
reused afterwards.
|
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
|
## Customization
|
||||||
|
|
||||||
@@ -75,7 +82,7 @@ Requirements: Go 1.25+ and `ffmpeg` / `ffprobe` on `PATH` (only for
|
|||||||
`--generate`).
|
`--generate`).
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go run ./src --generate --video video.mp4
|
go run ./src --generate --video video.mp4 --resolution 512
|
||||||
go run ./src
|
go run ./src
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
trollssh:
|
trollssh:
|
||||||
image: ghcr.io/yuzuzensai/trollssh:latest
|
image: ghcr.io/yuzuzensai/trollssh:v1.0.1
|
||||||
container_name: trollssh
|
container_name: trollssh
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
+42
-2
@@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -168,7 +169,7 @@ func TestFrameToAscii(t *testing.T) {
|
|||||||
// Below threshold -> first ramp char; full brightness -> last.
|
// Below threshold -> first ramp char; full brightness -> last.
|
||||||
opts := asciiOptions{brightnessThreshold: 40, charset: "standard"}
|
opts := asciiOptions{brightnessThreshold: 40, charset: "standard"}
|
||||||
ramp := []rune(resolveCharset("standard"))
|
ramp := []rune(resolveCharset("standard"))
|
||||||
out := []rune(frameToAscii([]byte{0, 255}, opts))
|
out := []rune(frameToAscii([]byte{0, 255}, ramp, opts))
|
||||||
if out[0] != ramp[0] {
|
if out[0] != ramp[0] {
|
||||||
t.Errorf("dark px = %q, want %q", out[0], ramp[0])
|
t.Errorf("dark px = %q, want %q", out[0], ramp[0])
|
||||||
}
|
}
|
||||||
@@ -180,12 +181,51 @@ func TestFrameToAscii(t *testing.T) {
|
|||||||
func TestFrameToAsciiInvert(t *testing.T) {
|
func TestFrameToAsciiInvert(t *testing.T) {
|
||||||
opts := asciiOptions{brightnessThreshold: 40, charset: "standard", invert: true}
|
opts := asciiOptions{brightnessThreshold: 40, charset: "standard", invert: true}
|
||||||
ramp := []rune(resolveCharset("standard"))
|
ramp := []rune(resolveCharset("standard"))
|
||||||
out := []rune(frameToAscii([]byte{255}, opts))
|
out := []rune(frameToAscii([]byte{255}, ramp, opts))
|
||||||
if out[0] != ramp[0] {
|
if out[0] != ramp[0] {
|
||||||
t.Errorf("inverted bright = %q, want %q", out[0], ramp[0])
|
t.Errorf("inverted bright = %q, want %q", out[0], ramp[0])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
func TestConnectionTracker(t *testing.T) {
|
||||||
tr := newConnectionTracker()
|
tr := newConnectionTracker()
|
||||||
tr.increment("1.2.3.4")
|
tr.increment("1.2.3.4")
|
||||||
|
|||||||
+2
-2
@@ -28,7 +28,7 @@ type Config struct {
|
|||||||
MaxAuthAttempts int
|
MaxAuthAttempts int
|
||||||
HandshakeTimeout time.Duration
|
HandshakeTimeout time.Duration
|
||||||
MaxDimension int
|
MaxDimension int
|
||||||
FrameResolution int
|
RenderCacheMB int
|
||||||
BrightnessThreshold int
|
BrightnessThreshold int
|
||||||
Charset string
|
Charset string
|
||||||
Invert bool
|
Invert bool
|
||||||
@@ -126,7 +126,7 @@ func loadConfig() Config {
|
|||||||
MaxAuthAttempts: envInt("MAX_AUTH_ATTEMPTS", 6, 1, maxInt),
|
MaxAuthAttempts: envInt("MAX_AUTH_ATTEMPTS", 6, 1, maxInt),
|
||||||
HandshakeTimeout: envDurationMs("HANDSHAKE_TIMEOUT", 10*time.Second),
|
HandshakeTimeout: envDurationMs("HANDSHAKE_TIMEOUT", 10*time.Second),
|
||||||
MaxDimension: envInt("MAX_DIMENSION", 512, 1, 4096),
|
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),
|
BrightnessThreshold: envInt("BRIGHTNESS_THRESHOLD", 40, 0, 100),
|
||||||
Charset: envString("CHARSET", "detailed"),
|
Charset: envString("CHARSET", "detailed"),
|
||||||
Invert: envBool("INVERT", false),
|
Invert: envBool("INVERT", false),
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@ func writeTSF(output string, data *FramesContainer) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadTSF(filename string) (*FramesContainer, error) {
|
func loadTSF(filename string) (*FramesContainer, error) {
|
||||||
raw, err := os.ReadFile(filename)
|
raw, err := readFrameFile(filename)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-60
@@ -7,8 +7,10 @@ import (
|
|||||||
"image"
|
"image"
|
||||||
"image/color"
|
"image/color"
|
||||||
"image/jpeg"
|
"image/jpeg"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"golang.org/x/image/draw"
|
"golang.org/x/image/draw"
|
||||||
)
|
)
|
||||||
@@ -62,18 +64,49 @@ func resolveCharset(charset string) string {
|
|||||||
return charset
|
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, tier colorTier) (draw.Image, error) {
|
||||||
src, err := jpeg.Decode(bytes.NewReader(frame))
|
src, err := jpeg.Decode(bytes.NewReader(frame))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
rect := image.Rect(0, 0, width, height)
|
||||||
var dst draw.Image
|
var dst draw.Image
|
||||||
var bg color.Color
|
var bg color.Color
|
||||||
if tier == colorTierNone {
|
if tier == colorTierNone {
|
||||||
dst = image.NewGray(image.Rect(0, 0, width, height))
|
dst = &image.Gray{Pix: pix[:width*height], Stride: width, Rect: rect}
|
||||||
bg = color.Gray{0}
|
bg = color.Gray{0}
|
||||||
} else {
|
} else {
|
||||||
dst = image.NewNRGBA(image.Rect(0, 0, width, height))
|
dst = &image.NRGBA{Pix: pix[:4*width*height], Stride: 4 * width, Rect: rect}
|
||||||
bg = color.Black
|
bg = color.Black
|
||||||
}
|
}
|
||||||
if keepAspectRatio {
|
if keepAspectRatio {
|
||||||
@@ -114,16 +147,17 @@ func rampIndex(brightness, threshold, total int, invert bool) int {
|
|||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
func frameToAscii(pixels []byte, options asciiOptions) string {
|
func frameToAscii(pixels []byte, ramp []rune, options asciiOptions) string {
|
||||||
ramp := []rune(resolveCharset(options.charset))
|
|
||||||
total := len(ramp)
|
total := len(ramp)
|
||||||
var b strings.Builder
|
buf := getOutBuf(len(pixels) * 4)
|
||||||
for _, p := range pixels {
|
for _, p := range pixels {
|
||||||
brightness := int(p) * 100 / 255
|
brightness := int(p) * 100 / 255
|
||||||
index := rampIndex(brightness, options.brightnessThreshold, total, options.invert)
|
index := rampIndex(brightness, options.brightnessThreshold, total, options.invert)
|
||||||
b.WriteRune(ramp[index])
|
buf = utf8.AppendRune(buf, ramp[index])
|
||||||
}
|
}
|
||||||
return b.String()
|
ascii := string(buf)
|
||||||
|
putOutBuf(buf)
|
||||||
|
return ascii
|
||||||
}
|
}
|
||||||
|
|
||||||
const ansiReset = "\x1b[0m"
|
const ansiReset = "\x1b[0m"
|
||||||
@@ -147,11 +181,25 @@ func quantize256(r, g, b uint8) int {
|
|||||||
return 16 + 36*toLevel(r) + 6*toLevel(g) + toLevel(b)
|
return 16 + 36*toLevel(r) + 6*toLevel(g) + toLevel(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func frameToAnsi(img *image.NRGBA, options asciiOptions, tier colorTier) string {
|
func appendColor(buf []byte, r, g, b uint8, tier colorTier) []byte {
|
||||||
ramp := []rune(resolveCharset(options.charset))
|
if tier == colorTierTrueColor {
|
||||||
|
buf = append(buf, "\x1b[38;2;"...)
|
||||||
|
buf = strconv.AppendUint(buf, uint64(r), 10)
|
||||||
|
buf = append(buf, ';')
|
||||||
|
buf = strconv.AppendUint(buf, uint64(g), 10)
|
||||||
|
buf = append(buf, ';')
|
||||||
|
buf = strconv.AppendUint(buf, uint64(b), 10)
|
||||||
|
} else {
|
||||||
|
buf = append(buf, "\x1b[38;5;"...)
|
||||||
|
buf = strconv.AppendUint(buf, uint64(quantize256(r, g, b)), 10)
|
||||||
|
}
|
||||||
|
return append(buf, 'm')
|
||||||
|
}
|
||||||
|
|
||||||
|
func frameToAnsi(img *image.NRGBA, ramp []rune, options asciiOptions, tier colorTier) string {
|
||||||
total := len(ramp)
|
total := len(ramp)
|
||||||
bounds := img.Bounds()
|
bounds := img.Bounds()
|
||||||
var b strings.Builder
|
buf := getOutBuf(bounds.Dx() * bounds.Dy() * 16)
|
||||||
var lastR, lastG, lastB uint8
|
var lastR, lastG, lastB uint8
|
||||||
first := true
|
first := true
|
||||||
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
|
||||||
@@ -161,33 +209,29 @@ func frameToAnsi(img *image.NRGBA, options asciiOptions, tier colorTier) string
|
|||||||
brightness := (int(r)*299 + int(g)*587 + int(bl)*114) / 255 / 10
|
brightness := (int(r)*299 + int(g)*587 + int(bl)*114) / 255 / 10
|
||||||
index := rampIndex(brightness, options.brightnessThreshold, total, options.invert)
|
index := rampIndex(brightness, options.brightnessThreshold, total, options.invert)
|
||||||
if first || r != lastR || g != lastG || bl != lastB {
|
if first || r != lastR || g != lastG || bl != lastB {
|
||||||
if tier == colorTierTrueColor {
|
buf = appendColor(buf, r, g, bl, tier)
|
||||||
fmt.Fprintf(&b, "\x1b[38;2;%d;%d;%dm", r, g, bl)
|
|
||||||
} else {
|
|
||||||
fmt.Fprintf(&b, "\x1b[38;5;%dm", quantize256(r, g, bl))
|
|
||||||
}
|
|
||||||
lastR, lastG, lastB = r, g, bl
|
lastR, lastG, lastB = r, g, bl
|
||||||
first = false
|
first = false
|
||||||
}
|
}
|
||||||
b.WriteRune(ramp[index])
|
buf = utf8.AppendRune(buf, ramp[index])
|
||||||
}
|
}
|
||||||
if y < bounds.Max.Y-1 {
|
if y < bounds.Max.Y-1 {
|
||||||
b.WriteString(ansiReset + "\r\n")
|
buf = append(buf, ansiReset+"\r\n"...)
|
||||||
first = true
|
first = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b.WriteString(ansiReset)
|
buf = append(buf, ansiReset...)
|
||||||
return b.String()
|
ascii := string(buf)
|
||||||
|
putOutBuf(buf)
|
||||||
|
return ascii
|
||||||
}
|
}
|
||||||
|
|
||||||
type FrameRenderer struct {
|
type renderCache struct {
|
||||||
colorFrames [][]byte
|
mu sync.Mutex
|
||||||
options asciiOptions
|
maxBytes int64
|
||||||
maxEntries int
|
size int64
|
||||||
|
entries map[string]*list.Element
|
||||||
mu sync.Mutex
|
order *list.List
|
||||||
cache map[string]*list.Element
|
|
||||||
order *list.List
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type cacheEntry struct {
|
type cacheEntry struct {
|
||||||
@@ -195,52 +239,101 @@ type cacheEntry struct {
|
|||||||
ascii string
|
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
|
||||||
|
ramp []rune
|
||||||
|
cache *renderCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFrameRenderer(setID int, colorFrames [][]byte, options asciiOptions, cache *renderCache) *FrameRenderer {
|
||||||
return &FrameRenderer{
|
return &FrameRenderer{
|
||||||
|
setID: setID,
|
||||||
colorFrames: colorFrames,
|
colorFrames: colorFrames,
|
||||||
options: options,
|
options: options,
|
||||||
maxEntries: 4096,
|
ramp: []rune(resolveCharset(options.charset)),
|
||||||
cache: make(map[string]*list.Element),
|
cache: cache,
|
||||||
order: list.New(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FrameRenderer) render(index, width, height int, keepAspectRatio bool, tier colorTier) (string, error) {
|
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)
|
key := fmt.Sprintf("%d:%d:%dx%d:%t:%d", r.setID, index, width, height, keepAspectRatio, tier)
|
||||||
|
if ascii, ok := r.cache.get(key); ok {
|
||||||
r.mu.Lock()
|
|
||||||
if el, ok := r.cache[key]; ok {
|
|
||||||
r.order.MoveToBack(el)
|
|
||||||
ascii := el.Value.(*cacheEntry).ascii
|
|
||||||
r.mu.Unlock()
|
|
||||||
return ascii, nil
|
return ascii, nil
|
||||||
}
|
}
|
||||||
r.mu.Unlock()
|
|
||||||
|
|
||||||
|
n := width * height
|
||||||
|
if tier != colorTierNone {
|
||||||
|
n *= 4
|
||||||
|
}
|
||||||
|
pix := getPixBuf(n)
|
||||||
|
img, err := resizeFrame(r.colorFrames[index], pix, width, height, keepAspectRatio, tier)
|
||||||
|
if err != nil {
|
||||||
|
putPixBuf(pix)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
var ascii string
|
var ascii string
|
||||||
if tier == colorTierNone {
|
if tier == colorTierNone {
|
||||||
img, err := resizeFrame(r.colorFrames[index], width, height, keepAspectRatio, tier)
|
ascii = frameToAscii(img.(*image.Gray).Pix, r.ramp, r.options)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
ascii = frameToAscii(img.(*image.Gray).Pix, r.options)
|
|
||||||
} else {
|
} else {
|
||||||
img, err := resizeFrame(r.colorFrames[index], width, height, keepAspectRatio, tier)
|
ascii = frameToAnsi(img.(*image.NRGBA), r.ramp, r.options, tier)
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
ascii = frameToAnsi(img.(*image.NRGBA), r.options, tier)
|
|
||||||
}
|
}
|
||||||
|
putPixBuf(pix)
|
||||||
|
|
||||||
r.mu.Lock()
|
r.cache.put(key, ascii)
|
||||||
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()
|
|
||||||
return ascii, nil
|
return ascii, nil
|
||||||
}
|
}
|
||||||
|
|||||||
+40
-6
@@ -6,7 +6,9 @@ import (
|
|||||||
"os/signal"
|
"os/signal"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"runtime/debug"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
@@ -16,12 +18,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type cliArgs struct {
|
type cliArgs struct {
|
||||||
generate bool
|
generate bool
|
||||||
video string
|
video string
|
||||||
|
resolution int
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseArgs(argv []string) cliArgs {
|
func parseArgs(argv []string) cliArgs {
|
||||||
var args cliArgs
|
args := cliArgs{resolution: 512}
|
||||||
for i := 0; i < len(argv); i++ {
|
for i := 0; i < len(argv); i++ {
|
||||||
switch argv[i] {
|
switch argv[i] {
|
||||||
case "--generate", "-g":
|
case "--generate", "-g":
|
||||||
@@ -31,6 +34,13 @@ func parseArgs(argv []string) cliArgs {
|
|||||||
i++
|
i++
|
||||||
args.video = argv[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
|
return args
|
||||||
@@ -52,7 +62,7 @@ func resolveVideoPath(explicitPath string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateFrames(config Config, framesDir, videoArg string) {
|
func generateFrames(framesDir, videoArg string, resolution int) {
|
||||||
if videoArg == "" {
|
if videoArg == "" {
|
||||||
fail("No source video given. Pass --video <path>.")
|
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")
|
output := filepath.Join(framesDir, base+".tsf")
|
||||||
|
|
||||||
logInfo(fmt.Sprintf("Generating frames from %q -> %s", videoPath, output))
|
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()))
|
fail(fmt.Sprintf("Failed to generate frames from %q: %s", videoPath, err.Error()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -143,9 +153,33 @@ func loadAllFrames(framesDir string) []*FramesContainer {
|
|||||||
return results
|
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() {
|
func main() {
|
||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
logThreshold = resolveThreshold()
|
logThreshold = resolveThreshold()
|
||||||
|
applyMemoryLimit()
|
||||||
|
|
||||||
config := loadConfig()
|
config := loadConfig()
|
||||||
args := parseArgs(os.Args[1:])
|
args := parseArgs(os.Args[1:])
|
||||||
@@ -158,7 +192,7 @@ func main() {
|
|||||||
framesDir := filepath.Join(cwd, "frames")
|
framesDir := filepath.Join(cwd, "frames")
|
||||||
|
|
||||||
if args.generate {
|
if args.generate {
|
||||||
generateFrames(config, framesDir, args.video)
|
generateFrames(framesDir, args.video, args.resolution)
|
||||||
return
|
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
|
||||||
|
}
|
||||||
+24
-5
@@ -13,7 +13,14 @@ import (
|
|||||||
"golang.org/x/crypto/ssh"
|
"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 {
|
type ConnectionTracker struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
@@ -98,15 +105,16 @@ func clampDimension(value, max int) int {
|
|||||||
func createServer(deps ServerDeps) *Server {
|
func createServer(deps ServerDeps) *Server {
|
||||||
config := deps.Config
|
config := deps.Config
|
||||||
|
|
||||||
|
cache := newRenderCache(int64(config.RenderCacheMB) << 20)
|
||||||
sets := make([]frameSet, len(deps.VideoSets))
|
sets := make([]frameSet, len(deps.VideoSets))
|
||||||
for i, data := range deps.VideoSets {
|
for i, data := range deps.VideoSets {
|
||||||
sets[i] = frameSet{
|
sets[i] = frameSet{
|
||||||
data: data,
|
data: data,
|
||||||
renderer: newFrameRenderer(data.ColorFrames, asciiOptions{
|
renderer: newFrameRenderer(i, data.ColorFrames, asciiOptions{
|
||||||
brightnessThreshold: config.BrightnessThreshold,
|
brightnessThreshold: config.BrightnessThreshold,
|
||||||
charset: config.Charset,
|
charset: config.Charset,
|
||||||
invert: config.Invert,
|
invert: config.Invert,
|
||||||
}),
|
}, cache),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,6 +391,8 @@ func (s *Server) playVideo(
|
|||||||
w, h := size.get()
|
w, h := size.get()
|
||||||
logDebug(fmt.Sprintf("Terminal size %dx%d for %s", w, h, ip))
|
logDebug(fmt.Sprintf("Terminal size %dx%d for %s", w, h, ip))
|
||||||
|
|
||||||
|
defer func() { _, _ = channel.Write([]byte(showCursor)) }()
|
||||||
|
|
||||||
if s.fakeLogin != nil {
|
if s.fakeLogin != nil {
|
||||||
_, _ = channel.Write([]byte(clearScreen))
|
_, _ = channel.Write([]byte(clearScreen))
|
||||||
_, _ = channel.Write([]byte(*s.fakeLogin))
|
_, _ = channel.Write([]byte(*s.fakeLogin))
|
||||||
@@ -435,6 +445,8 @@ func (s *Server) playVideo(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_, _ = channel.Write([]byte(hideCursor))
|
||||||
|
|
||||||
frameInterval := func() time.Duration {
|
frameInterval := func() time.Duration {
|
||||||
return time.Duration(float64(time.Second) / current.data.FPS)
|
return time.Duration(float64(time.Second) / current.data.FPS)
|
||||||
}
|
}
|
||||||
@@ -444,6 +456,7 @@ func (s *Server) playVideo(
|
|||||||
|
|
||||||
currentFrame := 0
|
currentFrame := 0
|
||||||
loopCount := 0
|
loopCount := 0
|
||||||
|
lastW, lastH := 0, 0
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -457,6 +470,7 @@ func (s *Server) playVideo(
|
|||||||
setIndex = (setIndex + delta + len(s.sets)) % len(s.sets)
|
setIndex = (setIndex + delta + len(s.sets)) % len(s.sets)
|
||||||
current = s.sets[setIndex]
|
current = s.sets[setIndex]
|
||||||
currentFrame = 0
|
currentFrame = 0
|
||||||
|
lastW, lastH = 0, 0
|
||||||
logDebug(fmt.Sprintf("%s switched to %q", ip, current.data.Name))
|
logDebug(fmt.Sprintf("%s switched to %q", ip, current.data.Name))
|
||||||
ticker.Reset(frameInterval())
|
ticker.Reset(frameInterval())
|
||||||
|
|
||||||
@@ -469,7 +483,12 @@ func (s *Server) playVideo(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := channel.Write([]byte(clearScreen + ascii)); err != nil {
|
prefix := homeCursor
|
||||||
|
if w != lastW || h != lastH {
|
||||||
|
prefix = clearScreen
|
||||||
|
lastW, lastH = w, h
|
||||||
|
}
|
||||||
|
if _, err := channel.Write([]byte(syncStart + prefix + ascii + syncEnd)); err != nil {
|
||||||
closeSession()
|
closeSession()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -482,7 +501,7 @@ func (s *Server) playVideo(
|
|||||||
currentFrame = 0
|
currentFrame = 0
|
||||||
loopCount++
|
loopCount++
|
||||||
if config.MaxLoop > 0 && loopCount >= config.MaxLoop {
|
if config.MaxLoop > 0 && loopCount >= config.MaxLoop {
|
||||||
_, _ = channel.Write([]byte(clearScreen))
|
_, _ = channel.Write([]byte(showCursor + clearScreen))
|
||||||
if s.goodbye != nil {
|
if s.goodbye != nil {
|
||||||
_, _ = channel.Write([]byte(*s.goodbye))
|
_, _ = channel.Write([]byte(*s.goodbye))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user