8 Commits
Author SHA1 Message Date
dependabot[bot]andyuzu be6cbe484a build(deps): bump golang.org/x/image from 0.44.0 to 0.45.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.44.0 to 0.45.0.
- [Commits](https://github.com/golang/image/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-09-09 22:56:00 +07:00
yuzuandGitHub 71a414d1ca 👷 ci: Rename ci.yml to ci.yaml 2026-07-29 19:45:17 +07:00
yuzuandGitHub 4a43de0a95 👷 ci: Rename dependabot.yml to dependabot.yaml 2026-07-29 19:44:59 +07:00
yuzu 0514931320 📚 docs: bump README image tag to 1.2.0 2026-07-29 19:33:20 +07:00
yuzu 5ff2453f15 🐛 fix: correct glob pattern for semver tag trigger in CI 2026-07-29 19:24:53 +07:00
yuzu 3107a452e6 🔧 chore: drop v prefix from release tags and docker image tags 2026-07-29 18:41:40 +07:00
yuzu e1e6d80ef9 📚 docs: bump README image tag to v1.1.3 2026-07-17 02:14:43 +07:00
yuzu ee9d36dd4f 🚀 perf: make render-cache compression opt-in, default off 2026-07-17 02:12:05 +07:00
12 changed files with 71 additions and 49 deletions
+3
View File
@@ -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
+1 -1
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: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
View File
@@ -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}
+1 -1
View File
@@ -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 -2
View File
@@ -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=
+2
View File
@@ -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),
+12 -5
View File
@@ -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)
+29 -21
View File
@@ -43,27 +43,35 @@ 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) {
fc := loadBenchSet(b) for _, compress := range []bool{false, true} {
r := NewRenderer(0, fc.ColorFrames, Options{ name := "uncompressed"
BrightnessThreshold: 40, if compress {
Charset: "detailed", name = "compressed"
}, NewCache(8<<20))
frame, err := r.Render(0, 120, 40, false, ColorTierTrueColor)
if err != nil {
b.Fatal(err)
}
b.SetBytes(int64(len(frame)))
b.ReportAllocs()
var failures atomic.Uint64
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if _, err := r.Render(0, 120, 40, false, ColorTierTrueColor); err != nil {
failures.Add(1)
}
} }
}) b.Run(name, func(b *testing.B) {
if failures.Load() != 0 { fc := loadBenchSet(b)
b.Fatalf("render failures: %d", failures.Load()) r := NewRenderer(0, fc.ColorFrames, Options{
BrightnessThreshold: 40,
Charset: "detailed",
}, NewCache(8<<20, compress))
frame, err := r.Render(0, 120, 40, false, ColorTierTrueColor)
if err != nil {
b.Fatal(err)
}
b.SetBytes(int64(len(frame)))
b.ReportAllocs()
var failures atomic.Uint64
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if _, err := r.Render(0, 120, 40, false, ColorTierTrueColor); err != nil {
failures.Add(1)
}
}
})
if failures.Load() != 0 {
b.Fatalf("render failures: %d", failures.Load())
}
})
} }
} }
+16 -14
View File
@@ -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} {
want := incompressible(4096) c := NewCache(1<<20, compress)
c.put(cacheKey{}, want) want := incompressible(4096)
got, ok := c.get(cacheKey{}) c.put(cacheKey{}, want)
if !ok { got, ok := c.get(cacheKey{})
t.Fatal("entry should be cached") if !ok {
} t.Fatalf("compress=%v: entry should be cached", compress)
if !bytes.Equal(got, want) { }
t.Fatal("decompressed entry does not match original") if !bytes.Equal(got, want) {
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 {
+1 -1
View File
@@ -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{