4 Commits
11 changed files with 322 additions and 89 deletions
+8 -7
View File
@@ -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.
+12 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,32 +209,28 @@ 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
options asciiOptions
maxEntries int
mu sync.Mutex mu sync.Mutex
cache map[string]*list.Element maxBytes int64
size int64
entries map[string]*list.Element
order *list.List order *list.List
} }
@@ -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 &FrameRenderer{ return int64(len(key)+len(ascii)) + 128
colorFrames: colorFrames, }
options: options,
maxEntries: 4096, func newRenderCache(maxBytes int64) *renderCache {
cache: make(map[string]*list.Element), if maxBytes <= 0 {
return nil
}
return &renderCache{
maxBytes: maxBytes,
entries: make(map[string]*list.Element),
order: list.New(), order: list.New(),
} }
} }
func (r *FrameRenderer) render(index, width, height int, keepAspectRatio bool, tier colorTier) (string, error) { func (c *renderCache) get(key string) (string, bool) {
key := fmt.Sprintf("%d:%dx%d:%t:%d", index, width, height, keepAspectRatio, tier) 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
}
r.mu.Lock() func (c *renderCache) put(key, ascii string) {
if el, ok := r.cache[key]; ok { if c == nil {
r.order.MoveToBack(el) return
ascii := el.Value.(*cacheEntry).ascii }
r.mu.Unlock() 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{
setID: setID,
colorFrames: colorFrames,
options: options,
ramp: []rune(resolveCharset(options.charset)),
cache: cache,
}
}
func (r *FrameRenderer) render(index, width, height int, keepAspectRatio bool, tier colorTier) (string, error) {
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 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
} }
+38 -4
View File
@@ -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"
@@ -18,10 +20,11 @@ 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
} }
+9
View File
@@ -0,0 +1,9 @@
//go:build !unix
package main
import "os"
func readFrameFile(filename string) ([]byte, error) {
return os.ReadFile(filename)
}
+30
View File
@@ -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
View File
@@ -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))
} }