diff --git a/src/app_test.go b/src/app_test.go index db4d5f5..8089681 100644 --- a/src/app_test.go +++ b/src/app_test.go @@ -1,10 +1,14 @@ package main import ( + "bytes" "fmt" + "image" + "image/jpeg" "os" "path/filepath" "strings" + "sync" "testing" "time" ) @@ -169,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}, ramp, 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]) } @@ -181,12 +189,53 @@ 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}, ramp, 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) diff --git a/src/frames.go b/src/frames.go index 66b1ffc..35f0f11 100644 --- a/src/frames.go +++ b/src/frames.go @@ -94,23 +94,16 @@ func putOutBuf(b []byte) { outPool.Put(&b) } -func resizeFrame(frame, pix []byte, width, height int, keepAspectRatio bool, tier colorTier) (draw.Image, error) { +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 } rect := image.Rect(0, 0, width, height) - var dst draw.Image - var bg color.Color - if tier == colorTierNone { - dst = &image.Gray{Pix: pix[:width*height], Stride: width, Rect: rect} - bg = color.Gray{0} - } else { - dst = &image.NRGBA{Pix: pix[:4*width*height], Stride: 4 * width, Rect: rect} - bg = color.Black - } + + 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)) @@ -131,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 { @@ -147,13 +149,12 @@ func rampIndex(brightness, threshold, total int, invert bool) int { return index } -func frameToAscii(pixels []byte, ramp []rune, options asciiOptions) string { - total := len(ramp) - buf := getOutBuf(len(pixels) * 4) - for _, p := range pixels { - brightness := int(p) * 100 / 255 - index := rampIndex(brightness, options.brightnessThreshold, total, options.invert) - buf = utf8.AppendRune(buf, 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]...) } ascii := string(buf) putOutBuf(buf) @@ -164,11 +165,18 @@ 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 } @@ -176,28 +184,31 @@ 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 appendColor(buf []byte, r, g, b uint8, tier colorTier) []byte { if tier == colorTierTrueColor { buf = append(buf, "\x1b[38;2;"...) - buf = strconv.AppendUint(buf, uint64(r), 10) + buf = append(buf, decimal[r]...) buf = append(buf, ';') - buf = strconv.AppendUint(buf, uint64(g), 10) + buf = append(buf, decimal[g]...) buf = append(buf, ';') - buf = strconv.AppendUint(buf, uint64(b), 10) + buf = append(buf, decimal[b]...) } else { buf = append(buf, "\x1b[38;5;"...) - buf = strconv.AppendUint(buf, uint64(quantize256(r, g, b)), 10) + buf = append(buf, decimal[quantize256(r, g, b)]...) } return append(buf, 'm') } -func frameToAnsi(img *image.NRGBA, ramp []rune, options asciiOptions, tier colorTier) string { - total := len(ramp) +func frameToAnsi(img *image.RGBA, rampLUT *[101][]byte, tier colorTier) string { bounds := img.Bounds() buf := getOutBuf(bounds.Dx() * bounds.Dy() * 16) var lastR, lastG, lastB uint8 @@ -207,13 +218,12 @@ func frameToAnsi(img *image.NRGBA, ramp []rune, options asciiOptions, tier color 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 { buf = appendColor(buf, r, g, bl, tier) lastR, lastG, lastB = r, g, bl first = false } - buf = utf8.AppendRune(buf, ramp[index]) + buf = append(buf, rampLUT[brightness]...) } if y < bounds.Max.Y-1 { buf = append(buf, ansiReset+"\r\n"...) @@ -296,17 +306,22 @@ type FrameRenderer struct { setID int colorFrames [][]byte options asciiOptions - ramp []rune + 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, - ramp: []rune(resolveCharset(options.charset)), + rampLUT: buildRampLUT(ramp, options), cache: cache, + inflight: make(map[string]chan struct{}), } } @@ -316,21 +331,41 @@ func (r *FrameRenderer) render(index, width, height int, keepAspectRatio bool, t return ascii, nil } - n := width * height - if tier != colorTierNone { - n *= 4 + 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(n) - img, err := resizeFrame(r.colorFrames[index], pix, width, height, keepAspectRatio, tier) + + 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 { - ascii = frameToAscii(img.(*image.Gray).Pix, r.ramp, r.options) + ascii = frameToAscii(img, r.rampLUT) } else { - ascii = frameToAnsi(img.(*image.NRGBA), r.ramp, r.options, tier) + ascii = frameToAnsi(img, r.rampLUT, tier) } putPixBuf(pix) diff --git a/src/render_bench_test.go b/src/render_bench_test.go new file mode 100644 index 0000000..1cf0640 --- /dev/null +++ b/src/render_bench_test.go @@ -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) } diff --git a/src/server.go b/src/server.go index d4d1413..f8afa9c 100644 --- a/src/server.go +++ b/src/server.go @@ -457,6 +457,7 @@ func (s *Server) playVideo( currentFrame := 0 loopCount := 0 lastW, lastH := 0, 0 + var writeBuf []byte for { select { @@ -488,7 +489,11 @@ func (s *Server) playVideo( prefix = clearScreen lastW, lastH = w, h } - if _, err := channel.Write([]byte(syncStart + prefix + ascii + syncEnd)); err != nil { + 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 }