mirror of
https://github.com/YuzuZensai/TrollSSH.git
synced 2026-09-13 20:19:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be6cbe484a | ||
|
|
71a414d1ca | ||
|
|
4a43de0a95 | ||
|
|
0514931320
|
||
|
|
5ff2453f15
|
||
|
|
3107a452e6
|
||
|
|
e1e6d80ef9
|
||
|
|
ee9d36dd4f
|
@@ -31,6 +31,9 @@ MAX_TERMINAL_CELLS=256000
|
|||||||
|
|
||||||
# Memory budget in MB for the rendered-frame cache (0 disables caching).
|
# Memory budget in MB for the rendered-frame cache (0 disables caching).
|
||||||
RENDER_CACHE_MB=256
|
RENDER_CACHE_MB=256
|
||||||
|
# Compress cached frames (flate). Cuts cache RAM ~2x but decompresses on every
|
||||||
|
# cache hit, so CPU rises with concurrent sessions. Off = zero-copy reads.
|
||||||
|
RENDER_CACHE_COMPRESS=false
|
||||||
|
|
||||||
# Go soft memory limit; set below your container limit to avoid OOM kills.
|
# Go soft memory limit; set below your container limit to avoid OOM kills.
|
||||||
GOMEMLIMIT=1GiB
|
GOMEMLIMIT=1GiB
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ name: CI
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: ["**"]
|
branches: ["**"]
|
||||||
tags: ["v*"]
|
tags: ["[0-9]*.[0-9]*.[0-9]*"]
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
- name: Determine push conditions
|
- name: Determine push conditions
|
||||||
id: should_push
|
id: should_push
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ github.event_name }}" == "push" && ( "${{ github.ref }}" == "refs/heads/main" || "${{ github.ref }}" == refs/tags/v* ) ]]; then
|
if [[ "${{ github.event_name }}" == "push" && ( "${{ github.ref }}" == "refs/heads/main" || "${{ github.ref }}" =~ ^refs/tags/[0-9]+\.[0-9]+\.[0-9]+$ ) ]]; then
|
||||||
echo "push=true" >> "$GITHUB_OUTPUT"
|
echo "push=true" >> "$GITHUB_OUTPUT"
|
||||||
else
|
else
|
||||||
echo "push=false" >> "$GITHUB_OUTPUT"
|
echo "push=false" >> "$GITHUB_OUTPUT"
|
||||||
@@ -64,7 +64,7 @@ jobs:
|
|||||||
images: ghcr.io/yuzuzensai/trollssh
|
images: ghcr.io/yuzuzensai/trollssh
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=latest,enable={{is_default_branch}}
|
type=raw,value=latest,enable={{is_default_branch}}
|
||||||
type=semver,pattern=v{{version}}
|
type=semver,pattern={{version}}
|
||||||
type=sha,prefix=sha-,format=short
|
type=sha,prefix=sha-,format=short
|
||||||
|
|
||||||
- name: Log in to GHCR
|
- name: Log in to GHCR
|
||||||
@@ -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:v1.1.1 trollssh --generate --video video.mp4 --resolution 512
|
ghcr.io/yuzuzensai/trollssh:1.2.0 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
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
trollssh:
|
trollssh:
|
||||||
image: ghcr.io/yuzuzensai/trollssh:v1.1.1
|
image: ghcr.io/yuzuzensai/trollssh:v1.1.3
|
||||||
container_name: trollssh
|
container_name: trollssh
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
mem_limit: ${MEMORY_LIMIT:-2g}
|
mem_limit: ${MEMORY_LIMIT:-2g}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ go 1.25.0
|
|||||||
require (
|
require (
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
golang.org/x/crypto v0.54.0
|
golang.org/x/crypto v0.54.0
|
||||||
golang.org/x/image v0.44.0
|
golang.org/x/image v0.45.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require golang.org/x/sys v0.47.0 // indirect
|
require golang.org/x/sys v0.47.0 // indirect
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
|||||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
golang.org/x/image v0.45.0 h1:FMb1nTbH5H9vF55SriQHgFw5GnNL9Jg6L25BwXKzhB0=
|
||||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
golang.org/x/image v0.45.0/go.mod h1:n62x/7RqlwXDvGsSU4u6IUTUf6KghUZ9Bt7cG/T9Fx4=
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type Config struct {
|
|||||||
MaxTerminalCells int
|
MaxTerminalCells int
|
||||||
SessionTimeout time.Duration
|
SessionTimeout time.Duration
|
||||||
RenderCacheMB int
|
RenderCacheMB int
|
||||||
|
RenderCacheCompress bool
|
||||||
BrightnessThreshold int
|
BrightnessThreshold int
|
||||||
Charset string
|
Charset string
|
||||||
Invert bool
|
Invert bool
|
||||||
@@ -133,6 +134,7 @@ func Load() Config {
|
|||||||
MaxTerminalCells: envInt("MAX_TERMINAL_CELLS", 500*512, 1, maxInt),
|
MaxTerminalCells: envInt("MAX_TERMINAL_CELLS", 500*512, 1, maxInt),
|
||||||
SessionTimeout: envDurationMs("SESSION_TIMEOUT", 10*time.Minute),
|
SessionTimeout: envDurationMs("SESSION_TIMEOUT", 10*time.Minute),
|
||||||
RenderCacheMB: envInt("RENDER_CACHE_MB", 256, 0, maxInt),
|
RenderCacheMB: envInt("RENDER_CACHE_MB", 256, 0, maxInt),
|
||||||
|
RenderCacheCompress: envBool("RENDER_CACHE_COMPRESS", false),
|
||||||
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),
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type cacheShard struct {
|
|||||||
|
|
||||||
type Cache struct {
|
type Cache struct {
|
||||||
shards []cacheShard
|
shards []cacheShard
|
||||||
|
compress bool
|
||||||
size atomic.Int64
|
size atomic.Int64
|
||||||
hits atomic.Uint64
|
hits atomic.Uint64
|
||||||
misses atomic.Uint64
|
misses atomic.Uint64
|
||||||
@@ -83,12 +84,12 @@ func decompressAscii(src []byte, origLen int) []byte {
|
|||||||
return buf.Bytes()
|
return buf.Bytes()
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewCache(maxBytes int64) *Cache {
|
func NewCache(maxBytes int64, compress bool) *Cache {
|
||||||
if maxBytes <= 0 {
|
if maxBytes <= 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
shardCount := int(min(int64(16), max(int64(1), maxBytes/(1<<20))))
|
shardCount := int(min(int64(16), max(int64(1), maxBytes/(1<<20))))
|
||||||
cache := &Cache{shards: make([]cacheShard, shardCount)}
|
cache := &Cache{shards: make([]cacheShard, shardCount), compress: compress}
|
||||||
for i := range cache.shards {
|
for i := range cache.shards {
|
||||||
cache.shards[i] = cacheShard{
|
cache.shards[i] = cacheShard{
|
||||||
maxBytes: maxBytes / int64(shardCount),
|
maxBytes: maxBytes / int64(shardCount),
|
||||||
@@ -130,6 +131,9 @@ func (c *Cache) get(key cacheKey) ([]byte, bool) {
|
|||||||
data, origLen := entry.data, entry.origLen
|
data, origLen := entry.data, entry.origLen
|
||||||
shard.mu.Unlock()
|
shard.mu.Unlock()
|
||||||
c.hits.Add(1)
|
c.hits.Add(1)
|
||||||
|
if !c.compress {
|
||||||
|
return data, true
|
||||||
|
}
|
||||||
return decompressAscii(data, origLen), true
|
return decompressAscii(data, origLen), true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +142,11 @@ func (c *Cache) put(key cacheKey, ascii []byte) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
shard := c.shard(key)
|
shard := c.shard(key)
|
||||||
compressed := compressAscii(ascii)
|
data, origLen := ascii, 0
|
||||||
cost := entryCost(key, compressed)
|
if c.compress {
|
||||||
|
data, origLen = compressAscii(ascii), len(ascii)
|
||||||
|
}
|
||||||
|
cost := entryCost(key, data)
|
||||||
if cost > shard.maxBytes {
|
if cost > shard.maxBytes {
|
||||||
c.rejections.Add(1)
|
c.rejections.Add(1)
|
||||||
return
|
return
|
||||||
@@ -149,7 +156,7 @@ func (c *Cache) put(key cacheKey, ascii []byte) {
|
|||||||
if _, ok := shard.entries[key]; ok {
|
if _, ok := shard.entries[key]; ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
entry := &cacheEntry{key: key, data: compressed, origLen: len(ascii), cost: cost}
|
entry := &cacheEntry{key: key, data: data, origLen: origLen, cost: cost}
|
||||||
shard.entries[key] = shard.order.PushBack(entry)
|
shard.entries[key] = shard.order.PushBack(entry)
|
||||||
shard.size += cost
|
shard.size += cost
|
||||||
c.size.Add(cost)
|
c.size.Add(cost)
|
||||||
|
|||||||
@@ -43,11 +43,17 @@ func BenchmarkRenderGray(b *testing.B) { benchRender(b, ColorTierNone, 120,
|
|||||||
func BenchmarkRenderTrueBig(b *testing.B) { benchRender(b, ColorTierTrueColor, 240, 70) }
|
func BenchmarkRenderTrueBig(b *testing.B) { benchRender(b, ColorTierTrueColor, 240, 70) }
|
||||||
|
|
||||||
func BenchmarkRenderCachedParallel(b *testing.B) {
|
func BenchmarkRenderCachedParallel(b *testing.B) {
|
||||||
|
for _, compress := range []bool{false, true} {
|
||||||
|
name := "uncompressed"
|
||||||
|
if compress {
|
||||||
|
name = "compressed"
|
||||||
|
}
|
||||||
|
b.Run(name, func(b *testing.B) {
|
||||||
fc := loadBenchSet(b)
|
fc := loadBenchSet(b)
|
||||||
r := NewRenderer(0, fc.ColorFrames, Options{
|
r := NewRenderer(0, fc.ColorFrames, Options{
|
||||||
BrightnessThreshold: 40,
|
BrightnessThreshold: 40,
|
||||||
Charset: "detailed",
|
Charset: "detailed",
|
||||||
}, NewCache(8<<20))
|
}, NewCache(8<<20, compress))
|
||||||
frame, err := r.Render(0, 120, 40, false, ColorTierTrueColor)
|
frame, err := r.Render(0, 120, 40, false, ColorTierTrueColor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatal(err)
|
b.Fatal(err)
|
||||||
@@ -66,4 +72,6 @@ func BenchmarkRenderCachedParallel(b *testing.B) {
|
|||||||
if failures.Load() != 0 {
|
if failures.Load() != 0 {
|
||||||
b.Fatalf("render failures: %d", failures.Load())
|
b.Fatalf("render failures: %d", failures.Load())
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ func TestRenderConcurrentSameKey(t *testing.T) {
|
|||||||
r := NewRenderer(0, [][]byte{jpegBuf.Bytes()}, Options{
|
r := NewRenderer(0, [][]byte{jpegBuf.Bytes()}, Options{
|
||||||
BrightnessThreshold: 40,
|
BrightnessThreshold: 40,
|
||||||
Charset: "standard",
|
Charset: "standard",
|
||||||
}, NewCache(1<<20))
|
}, NewCache(1<<20, false))
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
results := make([][]byte, 32)
|
results := make([][]byte, 32)
|
||||||
@@ -104,7 +104,7 @@ func TestRenderCacheEvictsByBytes(t *testing.T) {
|
|||||||
key := func(index int) cacheKey { return cacheKey{index: index} }
|
key := func(index int) cacheKey { return cacheKey{index: index} }
|
||||||
payload := incompressible(1000)
|
payload := incompressible(1000)
|
||||||
budget := 3 * entryCost(key(0), compressAscii(payload))
|
budget := 3 * entryCost(key(0), compressAscii(payload))
|
||||||
c := NewCache(budget)
|
c := NewCache(budget, true)
|
||||||
for i := range 5 {
|
for i := range 5 {
|
||||||
c.put(key(i), payload)
|
c.put(key(i), payload)
|
||||||
}
|
}
|
||||||
@@ -120,20 +120,22 @@ func TestRenderCacheEvictsByBytes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderCacheRoundTrips(t *testing.T) {
|
func TestRenderCacheRoundTrips(t *testing.T) {
|
||||||
c := NewCache(1 << 20)
|
for _, compress := range []bool{false, true} {
|
||||||
|
c := NewCache(1<<20, compress)
|
||||||
want := incompressible(4096)
|
want := incompressible(4096)
|
||||||
c.put(cacheKey{}, want)
|
c.put(cacheKey{}, want)
|
||||||
got, ok := c.get(cacheKey{})
|
got, ok := c.get(cacheKey{})
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("entry should be cached")
|
t.Fatalf("compress=%v: entry should be cached", compress)
|
||||||
}
|
}
|
||||||
if !bytes.Equal(got, want) {
|
if !bytes.Equal(got, want) {
|
||||||
t.Fatal("decompressed entry does not match original")
|
t.Fatalf("compress=%v: entry does not match original", compress)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderCacheDisabled(t *testing.T) {
|
func TestRenderCacheDisabled(t *testing.T) {
|
||||||
c := NewCache(0)
|
c := NewCache(0, false)
|
||||||
if c != nil {
|
if c != nil {
|
||||||
t.Fatal("zero budget should disable the cache")
|
t.Fatal("zero budget should disable the cache")
|
||||||
}
|
}
|
||||||
@@ -144,7 +146,7 @@ func TestRenderCacheDisabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderCacheRejectsOversizedEntry(t *testing.T) {
|
func TestRenderCacheRejectsOversizedEntry(t *testing.T) {
|
||||||
c := NewCache(256)
|
c := NewCache(256, true)
|
||||||
c.put(cacheKey{}, incompressible(10_000))
|
c.put(cacheKey{}, incompressible(10_000))
|
||||||
if _, ok := c.get(cacheKey{}); ok {
|
if _, ok := c.get(cacheKey{}); ok {
|
||||||
t.Error("entry larger than budget should not be cached")
|
t.Error("entry larger than budget should not be cached")
|
||||||
@@ -155,7 +157,7 @@ func TestRenderCacheRejectsOversizedEntry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderCacheAccountsCompressedSize(t *testing.T) {
|
func TestRenderCacheAccountsCompressedSize(t *testing.T) {
|
||||||
cache := NewCache(512)
|
cache := NewCache(512, true)
|
||||||
value := make([]byte, 1, 4096)
|
value := make([]byte, 1, 4096)
|
||||||
cache.put(cacheKey{}, value)
|
cache.put(cacheKey{}, value)
|
||||||
if _, ok := cache.get(cacheKey{}); !ok {
|
if _, ok := cache.get(cacheKey{}); !ok {
|
||||||
|
|||||||
@@ -232,7 +232,7 @@ func clampTermSize(cols, rows, maxDimension, maxCells, quantum int) (int, int) {
|
|||||||
func New(deps ServerDeps) *Server {
|
func New(deps ServerDeps) *Server {
|
||||||
cfg := deps.Config
|
cfg := deps.Config
|
||||||
|
|
||||||
cache := render.NewCache(int64(cfg.RenderCacheMB) << 20)
|
cache := render.NewCache(int64(cfg.RenderCacheMB)<<20, cfg.RenderCacheCompress)
|
||||||
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{
|
||||||
|
|||||||
Reference in New Issue
Block a user