feat(graphics): add headless snapshot harness
- commit
- 6ac336ace244bbe07e4643b5cd8dabe336353086
- author
- brice · · 2026-08-24 10:14 UTC
- parent
- 251cbdf
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
10 files · +488 -2
this diff holds invisible characters (direction controls, zero-width characters). They are shown as <U+XXXX> markers, never applied.
M README.md +16 -0
| @@ -60,6 +60,22 @@ separate from cells and history bytes; VTH3 remains text/history-only. Static | |||
| 60 | 60 | placements retain their configured pixel rectangles across text scrolling and | |
| 61 | 61 | reflow; row-anchored and relative-placement movement is unsupported. | |
| 62 | 62 | ||
| 63 | + | ### Headless graphics harness | |
| 64 | + | ||
| 65 | + | `cmd/graphics-harness` feeds a terminal byte capture into a fresh `Screen` and | |
| 66 | + | writes its active graphics snapshot as a PNG using an internal reference | |
| 67 | + | compositor. It is a development inspection tool, not a terminal emulator. | |
| 68 | + | ||
| 69 | + | ```sh | |
| 70 | + | go run ./cmd/graphics-harness \ | |
| 71 | + | -input internal/graphicsharness/testdata/demo.apc \ | |
| 72 | + | -output /tmp/graphics-harness.png \ | |
| 73 | + | -cols 4 -rows 4 -pixel-width 4 -pixel-height 4 -scale 64 | |
| 74 | + | ``` | |
| 75 | + | ||
| 76 | + | The included demo has an opaque blue background and a semi-transparent red | |
| 77 | + | overlay. Its output is suitable for direct image inspection. | |
| 78 | + | ||
| 63 | 79 | ## Checks | |
| 64 | 80 | ||
| 65 | 81 | ```sh | |
| @@ -60,6 +60,22 @@ separate from cells and history bytes; VTH3 remains text/history-only. Static | |||||
| 60 | placements retain their configured pixel rectangles across text scrolling and | 60 | placements retain their configured pixel rectangles across text scrolling and | ||
| 61 | reflow; row-anchored and relative-placement movement is unsupported. | 61 | reflow; row-anchored and relative-placement movement is unsupported. | ||
| 62 | 62 | ||||
| 63 | + | ### Headless graphics harness | |||
| 64 | + | ||||
| 65 | + | `cmd/graphics-harness` feeds a terminal byte capture into a fresh `Screen` and | |||
| 66 | + | writes its active graphics snapshot as a PNG using an internal reference | |||
| 67 | + | compositor. It is a development inspection tool, not a terminal emulator. | |||
| 68 | + | ||||
| 69 | + | ```sh | |||
| 70 | + | go run ./cmd/graphics-harness \ | |||
| 71 | + | -input internal/graphicsharness/testdata/demo.apc \ | |||
| 72 | + | -output /tmp/graphics-harness.png \ | |||
| 73 | + | -cols 4 -rows 4 -pixel-width 4 -pixel-height 4 -scale 64 | |||
| 74 | + | ``` | |||
| 75 | + | ||||
| 76 | + | The included demo has an opaque blue background and a semi-transparent red | |||
| 77 | + | overlay. Its output is suitable for direct image inspection. | |||
| 78 | + | ||||
| 63 | ## Checks | 79 | ## Checks | ||
| 64 | 80 | ||||
| 65 | ```sh | 81 | ```sh | ||
A cmd/graphics-harness/main.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | // Command graphics-harness renders Kitty graphics terminal input to a PNG for | |
| 2 | + | // local development inspection. It is not a terminal emulator. | |
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "flag" | |
| 7 | + | "fmt" | |
| 8 | + | "image" | |
| 9 | + | "image/png" | |
| 10 | + | "os" | |
| 11 | + | ||
| 12 | + | vt "github.com/bnema/vev-vt" | |
| 13 | + | "github.com/bnema/vev-vt/internal/graphicsharness" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | func main() { | |
| 17 | + | inputPath := flag.String("input", "", "path to a file containing raw terminal bytes") | |
| 18 | + | outputPath := flag.String("output", "graphics-harness.png", "path for the rendered PNG") | |
| 19 | + | cols := flag.Int("cols", 80, "terminal column count") | |
| 20 | + | rows := flag.Int("rows", 24, "terminal row count") | |
| 21 | + | pixelWidth := flag.Int("pixel-width", 640, "terminal viewport width in pixels") | |
| 22 | + | pixelHeight := flag.Int("pixel-height", 384, "terminal viewport height in pixels") | |
| 23 | + | scale := flag.Int("scale", 1, "nearest-neighbor output scale for inspection") | |
| 24 | + | flag.Parse() | |
| 25 | + | ||
| 26 | + | if *inputPath == "" { | |
| 27 | + | fail("-input is required") | |
| 28 | + | } | |
| 29 | + | input, err := os.ReadFile(*inputPath) | |
| 30 | + | if err != nil { | |
| 31 | + | fail("read input: %v", err) | |
| 32 | + | } | |
| 33 | + | frame, err := graphicsharness.RenderTerminal(input, vt.Geometry{ | |
| 34 | + | Cols: *cols, Rows: *rows, PixelWidth: *pixelWidth, PixelHeight: *pixelHeight, | |
| 35 | + | }) | |
| 36 | + | if err != nil { | |
| 37 | + | fail("render input: %v", err) | |
| 38 | + | } | |
| 39 | + | frame, err = scaleFrame(frame, *scale) | |
| 40 | + | if err != nil { | |
| 41 | + | fail("scale output: %v", err) | |
| 42 | + | } | |
| 43 | + | output, err := os.Create(*outputPath) | |
| 44 | + | if err != nil { | |
| 45 | + | fail("create output: %v", err) | |
| 46 | + | } | |
| 47 | + | if err := png.Encode(output, frame); err != nil { | |
| 48 | + | _ = output.Close() | |
| 49 | + | fail("encode output: %v", err) | |
| 50 | + | } | |
| 51 | + | if err := output.Close(); err != nil { | |
| 52 | + | fail("close output: %v", err) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func scaleFrame(source *image.RGBA, scale int) (*image.RGBA, error) { | |
| 57 | + | if scale < 1 { | |
| 58 | + | return nil, fmt.Errorf("scale must be positive") | |
| 59 | + | } | |
| 60 | + | width, height := source.Bounds().Dx(), source.Bounds().Dy() | |
| 61 | + | if uint64(width)*uint64(height)*uint64(scale)*uint64(scale) > 64<<20 { | |
| 62 | + | return nil, fmt.Errorf("scaled frame exceeds 67108864 pixels") | |
| 63 | + | } | |
| 64 | + | result := image.NewRGBA(image.Rect(0, 0, width*scale, height*scale)) | |
| 65 | + | for y := range height { | |
| 66 | + | for x := range width { | |
| 67 | + | pixel := source.RGBAAt(x, y) | |
| 68 | + | for dy := range scale { | |
| 69 | + | for dx := range scale { | |
| 70 | + | result.SetRGBA(x*scale+dx, y*scale+dy, pixel) | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | return result, nil | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func fail(format string, args ...any) { | |
| 79 | + | fmt.Fprintf(os.Stderr, "graphics-harness: "+format+"\n", args...) | |
| 80 | + | os.Exit(1) | |
| 81 | + | } | |
| @@ -0,0 +1,81 @@ | |||||
| 1 | + | // Command graphics-harness renders Kitty graphics terminal input to a PNG for | |||
| 2 | + | // local development inspection. It is not a terminal emulator. | |||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "flag" | |||
| 7 | + | "fmt" | |||
| 8 | + | "image" | |||
| 9 | + | "image/png" | |||
| 10 | + | "os" | |||
| 11 | + | ||||
| 12 | + | vt "github.com/bnema/vev-vt" | |||
| 13 | + | "github.com/bnema/vev-vt/internal/graphicsharness" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | func main() { | |||
| 17 | + | inputPath := flag.String("input", "", "path to a file containing raw terminal bytes") | |||
| 18 | + | outputPath := flag.String("output", "graphics-harness.png", "path for the rendered PNG") | |||
| 19 | + | cols := flag.Int("cols", 80, "terminal column count") | |||
| 20 | + | rows := flag.Int("rows", 24, "terminal row count") | |||
| 21 | + | pixelWidth := flag.Int("pixel-width", 640, "terminal viewport width in pixels") | |||
| 22 | + | pixelHeight := flag.Int("pixel-height", 384, "terminal viewport height in pixels") | |||
| 23 | + | scale := flag.Int("scale", 1, "nearest-neighbor output scale for inspection") | |||
| 24 | + | flag.Parse() | |||
| 25 | + | ||||
| 26 | + | if *inputPath == "" { | |||
| 27 | + | fail("-input is required") | |||
| 28 | + | } | |||
| 29 | + | input, err := os.ReadFile(*inputPath) | |||
| 30 | + | if err != nil { | |||
| 31 | + | fail("read input: %v", err) | |||
| 32 | + | } | |||
| 33 | + | frame, err := graphicsharness.RenderTerminal(input, vt.Geometry{ | |||
| 34 | + | Cols: *cols, Rows: *rows, PixelWidth: *pixelWidth, PixelHeight: *pixelHeight, | |||
| 35 | + | }) | |||
| 36 | + | if err != nil { | |||
| 37 | + | fail("render input: %v", err) | |||
| 38 | + | } | |||
| 39 | + | frame, err = scaleFrame(frame, *scale) | |||
| 40 | + | if err != nil { | |||
| 41 | + | fail("scale output: %v", err) | |||
| 42 | + | } | |||
| 43 | + | output, err := os.Create(*outputPath) | |||
| 44 | + | if err != nil { | |||
| 45 | + | fail("create output: %v", err) | |||
| 46 | + | } | |||
| 47 | + | if err := png.Encode(output, frame); err != nil { | |||
| 48 | + | _ = output.Close() | |||
| 49 | + | fail("encode output: %v", err) | |||
| 50 | + | } | |||
| 51 | + | if err := output.Close(); err != nil { | |||
| 52 | + | fail("close output: %v", err) | |||
| 53 | + | } | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func scaleFrame(source *image.RGBA, scale int) (*image.RGBA, error) { | |||
| 57 | + | if scale < 1 { | |||
| 58 | + | return nil, fmt.Errorf("scale must be positive") | |||
| 59 | + | } | |||
| 60 | + | width, height := source.Bounds().Dx(), source.Bounds().Dy() | |||
| 61 | + | if uint64(width)*uint64(height)*uint64(scale)*uint64(scale) > 64<<20 { | |||
| 62 | + | return nil, fmt.Errorf("scaled frame exceeds 67108864 pixels") | |||
| 63 | + | } | |||
| 64 | + | result := image.NewRGBA(image.Rect(0, 0, width*scale, height*scale)) | |||
| 65 | + | for y := range height { | |||
| 66 | + | for x := range width { | |||
| 67 | + | pixel := source.RGBAAt(x, y) | |||
| 68 | + | for dy := range scale { | |||
| 69 | + | for dx := range scale { | |||
| 70 | + | result.SetRGBA(x*scale+dx, y*scale+dy, pixel) | |||
| 71 | + | } | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | } | |||
| 75 | + | return result, nil | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func fail(format string, args ...any) { | |||
| 79 | + | fmt.Fprintf(os.Stderr, "graphics-harness: "+format+"\n", args...) | |||
| 80 | + | os.Exit(1) | |||
| 81 | + | } | |||
A cmd/graphics-harness/main_test.go +28 -0
| @@ -0,0 +1,28 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "image" | |
| 5 | + | "image/color" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestScaleFrameUsesNearestNeighbor(t *testing.T) { | |
| 12 | + | source := image.NewRGBA(image.Rect(0, 0, 1, 1)) | |
| 13 | + | source.SetRGBA(0, 0, color.RGBA{22, 62, 143, 255}) | |
| 14 | + | ||
| 15 | + | scaled, err := scaleFrame(source, 2) | |
| 16 | + | require.NoError(t, err) | |
| 17 | + | require.Equal(t, image.Rect(0, 0, 2, 2), scaled.Bounds()) | |
| 18 | + | for y := 0; y < 2; y++ { | |
| 19 | + | for x := 0; x < 2; x++ { | |
| 20 | + | require.Equal(t, color.RGBA{22, 62, 143, 255}, scaled.RGBAAt(x, y)) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func TestScaleFrameRejectsInvalidScale(t *testing.T) { | |
| 26 | + | _, err := scaleFrame(image.NewRGBA(image.Rect(0, 0, 1, 1)), 0) | |
| 27 | + | require.Error(t, err) | |
| 28 | + | } | |
| @@ -0,0 +1,28 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "image" | |||
| 5 | + | "image/color" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestScaleFrameUsesNearestNeighbor(t *testing.T) { | |||
| 12 | + | source := image.NewRGBA(image.Rect(0, 0, 1, 1)) | |||
| 13 | + | source.SetRGBA(0, 0, color.RGBA{22, 62, 143, 255}) | |||
| 14 | + | ||||
| 15 | + | scaled, err := scaleFrame(source, 2) | |||
| 16 | + | require.NoError(t, err) | |||
| 17 | + | require.Equal(t, image.Rect(0, 0, 2, 2), scaled.Bounds()) | |||
| 18 | + | for y := 0; y < 2; y++ { | |||
| 19 | + | for x := 0; x < 2; x++ { | |||
| 20 | + | require.Equal(t, color.RGBA{22, 62, 143, 255}, scaled.RGBAAt(x, y)) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func TestScaleFrameRejectsInvalidScale(t *testing.T) { | |||
| 26 | + | _, err := scaleFrame(image.NewRGBA(image.Rect(0, 0, 1, 1)), 0) | |||
| 27 | + | require.Error(t, err) | |||
| 28 | + | } | |||
M graphics/scene.go +2 -2
| @@ -894,8 +894,8 @@ func visiblePlacement(placement PlacementView, pixels PixelRect, cells CellRect, | |||
| 894 | 894 | } | |
| 895 | 895 | ||
| 896 | 896 | // mapSubRect maps a non-empty sub-rectangle from one rectangle's coordinate | |
| 897 | - | // space into another rectangle's coordinate space. It uses exact 128+-bit | |
| 898 | - | // arithmetic through math/big so products cannot wrap before division. | |
| 897 | + | // space into another rectangle's coordinate space. Its range mapping uses | |
| 898 | + | // exact 128-bit arithmetic so products cannot wrap before division. | |
| 899 | 899 | func mapSubRect(from, to, sub PixelRect) (PixelRect, bool) { | |
| 900 | 900 | if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsRect(from, sub) { | |
| 901 | 901 | return PixelRect{}, false | |
| @@ -894,8 +894,8 @@ func visiblePlacement(placement PlacementView, pixels PixelRect, cells CellRect, | |||||
| 894 | } | 894 | } | ||
| 895 | 895 | ||||
| 896 | // mapSubRect maps a non-empty sub-rectangle from one rectangle's coordinate | 896 | // mapSubRect maps a non-empty sub-rectangle from one rectangle's coordinate | ||
| 897 | - | // space into another rectangle's coordinate space. It uses exact 128+-bit | 897 | + | // space into another rectangle's coordinate space. Its range mapping uses |
| 898 | - | // arithmetic through math/big so products cannot wrap before division. | 898 | + | // exact 128-bit arithmetic so products cannot wrap before division. |
| 899 | func mapSubRect(from, to, sub PixelRect) (PixelRect, bool) { | 899 | func mapSubRect(from, to, sub PixelRect) (PixelRect, bool) { | ||
| 900 | if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsRect(from, sub) { | 900 | if !from.Valid() || !to.Valid() || !sub.Valid() || from.Empty() || to.Empty() || sub.Empty() || !containsRect(from, sub) { | ||
| 901 | return PixelRect{}, false | 901 | return PixelRect{}, false | ||
A internal/graphicsharness/render.go +170 -0
| @@ -0,0 +1,170 @@ | |||
| 1 | + | // Package graphicsharness provides a headless reference compositor for | |
| 2 | + | // inspecting graphics snapshots during development. It is intentionally | |
| 3 | + | // internal: vev-vt remains renderer-neutral in its public API. | |
| 4 | + | package graphicsharness | |
| 5 | + | ||
| 6 | + | import ( | |
| 7 | + | "bytes" | |
| 8 | + | "fmt" | |
| 9 | + | "image" | |
| 10 | + | "image/color" | |
| 11 | + | "image/png" | |
| 12 | + | "math/bits" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/vev-vt/graphics" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | const maxCanvasPixels = uint64(64 << 20) | |
| 18 | + | ||
| 19 | + | // Render composites a graphics snapshot into an RGBA frame covering viewport. | |
| 20 | + | // Placement pixels are sampled with nearest-neighbor scaling in layer and | |
| 21 | + | // insertion order. The viewport's origin is preserved for placement geometry | |
| 22 | + | // but maps to (0, 0) in the returned frame. | |
| 23 | + | func Render(snapshot *graphics.Snapshot, viewport graphics.PixelRect) (*image.RGBA, error) { | |
| 24 | + | if !viewport.Valid() || viewport.Empty() { | |
| 25 | + | return nil, fmt.Errorf("render graphics: invalid viewport") | |
| 26 | + | } | |
| 27 | + | width, height, ok := imageDimensions(viewport.Width, viewport.Height) | |
| 28 | + | if !ok { | |
| 29 | + | return nil, fmt.Errorf("render graphics: viewport exceeds %d pixels", maxCanvasPixels) | |
| 30 | + | } | |
| 31 | + | frame := image.NewRGBA(image.Rect(0, 0, width, height)) | |
| 32 | + | if snapshot == nil { | |
| 33 | + | return frame, nil | |
| 34 | + | } | |
| 35 | + | decoded := make(map[graphics.AssetID]*image.RGBA) | |
| 36 | + | for _, placement := range snapshot.Placements() { | |
| 37 | + | asset, ok := snapshot.Asset(placement.AssetID()) | |
| 38 | + | if !ok { | |
| 39 | + | return nil, fmt.Errorf("render graphics: placement %s references an unavailable asset", placement.ID()) | |
| 40 | + | } | |
| 41 | + | source, ok := decoded[asset.ID()] | |
| 42 | + | if !ok { | |
| 43 | + | var err error | |
| 44 | + | source, err = decodeAsset(asset) | |
| 45 | + | if err != nil { | |
| 46 | + | return nil, err | |
| 47 | + | } | |
| 48 | + | decoded[asset.ID()] = source | |
| 49 | + | } | |
| 50 | + | if err := composite(frame, viewport, source, placement.Source(), placement.Destination()); err != nil { | |
| 51 | + | return nil, fmt.Errorf("render graphics: placement %s: %w", placement.ID(), err) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | return frame, nil | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func imageDimensions(width, height int64) (int, int, bool) { | |
| 58 | + | if width <= 0 || height <= 0 || uint64(width) > uint64(maxInt()) || uint64(height) > uint64(maxInt()) { | |
| 59 | + | return 0, 0, false | |
| 60 | + | } | |
| 61 | + | overflow, pixels := bits.Mul64(uint64(width), uint64(height)) | |
| 62 | + | if overflow != 0 || pixels > maxCanvasPixels { | |
| 63 | + | return 0, 0, false | |
| 64 | + | } | |
| 65 | + | return int(width), int(height), true | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func maxInt() int { return int(^uint(0) >> 1) } | |
| 69 | + | ||
| 70 | + | func decodeAsset(asset graphics.AssetView) (*image.RGBA, error) { | |
| 71 | + | width, height, ok := imageDimensions(asset.Width(), asset.Height()) | |
| 72 | + | if !ok { | |
| 73 | + | return nil, fmt.Errorf("decode asset %s: invalid dimensions", asset.ID()) | |
| 74 | + | } | |
| 75 | + | encoded := asset.Encoded() | |
| 76 | + | switch asset.Format() { | |
| 77 | + | case graphics.AssetFormatPNG: | |
| 78 | + | decoded, err := png.Decode(bytes.NewReader(encoded)) | |
| 79 | + | if err != nil { | |
| 80 | + | return nil, fmt.Errorf("decode asset %s: %w", asset.ID(), err) | |
| 81 | + | } | |
| 82 | + | if decoded.Bounds().Dx() != width || decoded.Bounds().Dy() != height { | |
| 83 | + | return nil, fmt.Errorf("decode asset %s: PNG dimensions do not match snapshot", asset.ID()) | |
| 84 | + | } | |
| 85 | + | result := image.NewRGBA(image.Rect(0, 0, width, height)) | |
| 86 | + | for y := range height { | |
| 87 | + | for x := range width { | |
| 88 | + | result.Set(x, y, decoded.At(decoded.Bounds().Min.X+x, decoded.Bounds().Min.Y+y)) | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | return result, nil | |
| 92 | + | case graphics.AssetFormatRGB, graphics.AssetFormatRGBA: | |
| 93 | + | channels := 3 | |
| 94 | + | if asset.Format() == graphics.AssetFormatRGBA { | |
| 95 | + | channels = 4 | |
| 96 | + | } | |
| 97 | + | expected := uint64(width) * uint64(height) * uint64(channels) | |
| 98 | + | if uint64(len(encoded)) != expected { | |
| 99 | + | return nil, fmt.Errorf("decode asset %s: raw payload has %d bytes, want %d", asset.ID(), len(encoded), expected) | |
| 100 | + | } | |
| 101 | + | result := image.NewRGBA(image.Rect(0, 0, width, height)) | |
| 102 | + | for y := range height { | |
| 103 | + | for x := range width { | |
| 104 | + | offset := (y*width + x) * channels | |
| 105 | + | r, g, b, a := encoded[offset], encoded[offset+1], encoded[offset+2], byte(255) | |
| 106 | + | if channels == 4 { | |
| 107 | + | a = encoded[offset+3] | |
| 108 | + | } | |
| 109 | + | result.SetRGBA(x, y, color.RGBA{R: premultiply(r, a), G: premultiply(g, a), B: premultiply(b, a), A: a}) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | return result, nil | |
| 113 | + | default: | |
| 114 | + | return nil, fmt.Errorf("decode asset %s: unsupported format %d", asset.ID(), asset.Format()) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func composite(frame *image.RGBA, viewport graphics.PixelRect, source *image.RGBA, sourceRect, destination graphics.PixelRect) error { | |
| 119 | + | visible, ok := destination.Intersect(viewport) | |
| 120 | + | if !ok { | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | for y := visible.Y; y < visible.Y+visible.Height; y++ { | |
| 124 | + | sy, ok := scaledCoordinate(y, destination.Y, destination.Height, sourceRect.Y, sourceRect.Height) | |
| 125 | + | if !ok { | |
| 126 | + | return fmt.Errorf("invalid vertical mapping") | |
| 127 | + | } | |
| 128 | + | for x := visible.X; x < visible.X+visible.Width; x++ { | |
| 129 | + | sx, ok := scaledCoordinate(x, destination.X, destination.Width, sourceRect.X, sourceRect.Width) | |
| 130 | + | if !ok || sx < 0 || sy < 0 || sx >= int64(source.Bounds().Dx()) || sy >= int64(source.Bounds().Dy()) { | |
| 131 | + | return fmt.Errorf("invalid source mapping") | |
| 132 | + | } | |
| 133 | + | dx, dy := int(x-viewport.X), int(y-viewport.Y) | |
| 134 | + | blend(frame, dx, dy, source.RGBAAt(int(sx), int(sy))) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | return nil | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func scaledCoordinate(value, destinationStart, destinationSize, sourceStart, sourceSize int64) (int64, bool) { | |
| 141 | + | if destinationSize <= 0 || sourceSize <= 0 || value < destinationStart { | |
| 142 | + | return 0, false | |
| 143 | + | } | |
| 144 | + | offset := value - destinationStart | |
| 145 | + | if offset >= destinationSize { | |
| 146 | + | return 0, false | |
| 147 | + | } | |
| 148 | + | hi, lo := bits.Mul64(uint64(offset), uint64(sourceSize)) | |
| 149 | + | if hi >= uint64(destinationSize) { | |
| 150 | + | return 0, false | |
| 151 | + | } | |
| 152 | + | quotient, _ := bits.Div64(hi, lo, uint64(destinationSize)) | |
| 153 | + | if quotient > uint64(^uint64(0)>>1) || sourceStart > int64(^uint64(0)>>1)-int64(quotient) { | |
| 154 | + | return 0, false | |
| 155 | + | } | |
| 156 | + | return sourceStart + int64(quotient), true | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | func premultiply(component, alpha byte) byte { | |
| 160 | + | return byte((uint16(component)*uint16(alpha) + 127) / 255) | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func blend(dst *image.RGBA, x, y int, src color.RGBA) { | |
| 164 | + | offset := dst.PixOffset(x, y) | |
| 165 | + | inverse := uint16(255 - src.A) | |
| 166 | + | dst.Pix[offset] = src.R + byte((uint16(dst.Pix[offset])*inverse+127)/255) | |
| 167 | + | dst.Pix[offset+1] = src.G + byte((uint16(dst.Pix[offset+1])*inverse+127)/255) | |
| 168 | + | dst.Pix[offset+2] = src.B + byte((uint16(dst.Pix[offset+2])*inverse+127)/255) | |
| 169 | + | dst.Pix[offset+3] = src.A + byte((uint16(dst.Pix[offset+3])*inverse+127)/255) | |
| 170 | + | } | |
| @@ -0,0 +1,170 @@ | |||||
| 1 | + | // Package graphicsharness provides a headless reference compositor for | |||
| 2 | + | // inspecting graphics snapshots during development. It is intentionally | |||
| 3 | + | // internal: vev-vt remains renderer-neutral in its public API. | |||
| 4 | + | package graphicsharness | |||
| 5 | + | ||||
| 6 | + | import ( | |||
| 7 | + | "bytes" | |||
| 8 | + | "fmt" | |||
| 9 | + | "image" | |||
| 10 | + | "image/color" | |||
| 11 | + | "image/png" | |||
| 12 | + | "math/bits" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/vev-vt/graphics" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | const maxCanvasPixels = uint64(64 << 20) | |||
| 18 | + | ||||
| 19 | + | // Render composites a graphics snapshot into an RGBA frame covering viewport. | |||
| 20 | + | // Placement pixels are sampled with nearest-neighbor scaling in layer and | |||
| 21 | + | // insertion order. The viewport's origin is preserved for placement geometry | |||
| 22 | + | // but maps to (0, 0) in the returned frame. | |||
| 23 | + | func Render(snapshot *graphics.Snapshot, viewport graphics.PixelRect) (*image.RGBA, error) { | |||
| 24 | + | if !viewport.Valid() || viewport.Empty() { | |||
| 25 | + | return nil, fmt.Errorf("render graphics: invalid viewport") | |||
| 26 | + | } | |||
| 27 | + | width, height, ok := imageDimensions(viewport.Width, viewport.Height) | |||
| 28 | + | if !ok { | |||
| 29 | + | return nil, fmt.Errorf("render graphics: viewport exceeds %d pixels", maxCanvasPixels) | |||
| 30 | + | } | |||
| 31 | + | frame := image.NewRGBA(image.Rect(0, 0, width, height)) | |||
| 32 | + | if snapshot == nil { | |||
| 33 | + | return frame, nil | |||
| 34 | + | } | |||
| 35 | + | decoded := make(map[graphics.AssetID]*image.RGBA) | |||
| 36 | + | for _, placement := range snapshot.Placements() { | |||
| 37 | + | asset, ok := snapshot.Asset(placement.AssetID()) | |||
| 38 | + | if !ok { | |||
| 39 | + | return nil, fmt.Errorf("render graphics: placement %s references an unavailable asset", placement.ID()) | |||
| 40 | + | } | |||
| 41 | + | source, ok := decoded[asset.ID()] | |||
| 42 | + | if !ok { | |||
| 43 | + | var err error | |||
| 44 | + | source, err = decodeAsset(asset) | |||
| 45 | + | if err != nil { | |||
| 46 | + | return nil, err | |||
| 47 | + | } | |||
| 48 | + | decoded[asset.ID()] = source | |||
| 49 | + | } | |||
| 50 | + | if err := composite(frame, viewport, source, placement.Source(), placement.Destination()); err != nil { | |||
| 51 | + | return nil, fmt.Errorf("render graphics: placement %s: %w", placement.ID(), err) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | return frame, nil | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func imageDimensions(width, height int64) (int, int, bool) { | |||
| 58 | + | if width <= 0 || height <= 0 || uint64(width) > uint64(maxInt()) || uint64(height) > uint64(maxInt()) { | |||
| 59 | + | return 0, 0, false | |||
| 60 | + | } | |||
| 61 | + | overflow, pixels := bits.Mul64(uint64(width), uint64(height)) | |||
| 62 | + | if overflow != 0 || pixels > maxCanvasPixels { | |||
| 63 | + | return 0, 0, false | |||
| 64 | + | } | |||
| 65 | + | return int(width), int(height), true | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func maxInt() int { return int(^uint(0) >> 1) } | |||
| 69 | + | ||||
| 70 | + | func decodeAsset(asset graphics.AssetView) (*image.RGBA, error) { | |||
| 71 | + | width, height, ok := imageDimensions(asset.Width(), asset.Height()) | |||
| 72 | + | if !ok { | |||
| 73 | + | return nil, fmt.Errorf("decode asset %s: invalid dimensions", asset.ID()) | |||
| 74 | + | } | |||
| 75 | + | encoded := asset.Encoded() | |||
| 76 | + | switch asset.Format() { | |||
| 77 | + | case graphics.AssetFormatPNG: | |||
| 78 | + | decoded, err := png.Decode(bytes.NewReader(encoded)) | |||
| 79 | + | if err != nil { | |||
| 80 | + | return nil, fmt.Errorf("decode asset %s: %w", asset.ID(), err) | |||
| 81 | + | } | |||
| 82 | + | if decoded.Bounds().Dx() != width || decoded.Bounds().Dy() != height { | |||
| 83 | + | return nil, fmt.Errorf("decode asset %s: PNG dimensions do not match snapshot", asset.ID()) | |||
| 84 | + | } | |||
| 85 | + | result := image.NewRGBA(image.Rect(0, 0, width, height)) | |||
| 86 | + | for y := range height { | |||
| 87 | + | for x := range width { | |||
| 88 | + | result.Set(x, y, decoded.At(decoded.Bounds().Min.X+x, decoded.Bounds().Min.Y+y)) | |||
| 89 | + | } | |||
| 90 | + | } | |||
| 91 | + | return result, nil | |||
| 92 | + | case graphics.AssetFormatRGB, graphics.AssetFormatRGBA: | |||
| 93 | + | channels := 3 | |||
| 94 | + | if asset.Format() == graphics.AssetFormatRGBA { | |||
| 95 | + | channels = 4 | |||
| 96 | + | } | |||
| 97 | + | expected := uint64(width) * uint64(height) * uint64(channels) | |||
| 98 | + | if uint64(len(encoded)) != expected { | |||
| 99 | + | return nil, fmt.Errorf("decode asset %s: raw payload has %d bytes, want %d", asset.ID(), len(encoded), expected) | |||
| 100 | + | } | |||
| 101 | + | result := image.NewRGBA(image.Rect(0, 0, width, height)) | |||
| 102 | + | for y := range height { | |||
| 103 | + | for x := range width { | |||
| 104 | + | offset := (y*width + x) * channels | |||
| 105 | + | r, g, b, a := encoded[offset], encoded[offset+1], encoded[offset+2], byte(255) | |||
| 106 | + | if channels == 4 { | |||
| 107 | + | a = encoded[offset+3] | |||
| 108 | + | } | |||
| 109 | + | result.SetRGBA(x, y, color.RGBA{R: premultiply(r, a), G: premultiply(g, a), B: premultiply(b, a), A: a}) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | return result, nil | |||
| 113 | + | default: | |||
| 114 | + | return nil, fmt.Errorf("decode asset %s: unsupported format %d", asset.ID(), asset.Format()) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func composite(frame *image.RGBA, viewport graphics.PixelRect, source *image.RGBA, sourceRect, destination graphics.PixelRect) error { | |||
| 119 | + | visible, ok := destination.Intersect(viewport) | |||
| 120 | + | if !ok { | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | for y := visible.Y; y < visible.Y+visible.Height; y++ { | |||
| 124 | + | sy, ok := scaledCoordinate(y, destination.Y, destination.Height, sourceRect.Y, sourceRect.Height) | |||
| 125 | + | if !ok { | |||
| 126 | + | return fmt.Errorf("invalid vertical mapping") | |||
| 127 | + | } | |||
| 128 | + | for x := visible.X; x < visible.X+visible.Width; x++ { | |||
| 129 | + | sx, ok := scaledCoordinate(x, destination.X, destination.Width, sourceRect.X, sourceRect.Width) | |||
| 130 | + | if !ok || sx < 0 || sy < 0 || sx >= int64(source.Bounds().Dx()) || sy >= int64(source.Bounds().Dy()) { | |||
| 131 | + | return fmt.Errorf("invalid source mapping") | |||
| 132 | + | } | |||
| 133 | + | dx, dy := int(x-viewport.X), int(y-viewport.Y) | |||
| 134 | + | blend(frame, dx, dy, source.RGBAAt(int(sx), int(sy))) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | return nil | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func scaledCoordinate(value, destinationStart, destinationSize, sourceStart, sourceSize int64) (int64, bool) { | |||
| 141 | + | if destinationSize <= 0 || sourceSize <= 0 || value < destinationStart { | |||
| 142 | + | return 0, false | |||
| 143 | + | } | |||
| 144 | + | offset := value - destinationStart | |||
| 145 | + | if offset >= destinationSize { | |||
| 146 | + | return 0, false | |||
| 147 | + | } | |||
| 148 | + | hi, lo := bits.Mul64(uint64(offset), uint64(sourceSize)) | |||
| 149 | + | if hi >= uint64(destinationSize) { | |||
| 150 | + | return 0, false | |||
| 151 | + | } | |||
| 152 | + | quotient, _ := bits.Div64(hi, lo, uint64(destinationSize)) | |||
| 153 | + | if quotient > uint64(^uint64(0)>>1) || sourceStart > int64(^uint64(0)>>1)-int64(quotient) { | |||
| 154 | + | return 0, false | |||
| 155 | + | } | |||
| 156 | + | return sourceStart + int64(quotient), true | |||
| 157 | + | } | |||
| 158 | + | ||||
| 159 | + | func premultiply(component, alpha byte) byte { | |||
| 160 | + | return byte((uint16(component)*uint16(alpha) + 127) / 255) | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func blend(dst *image.RGBA, x, y int, src color.RGBA) { | |||
| 164 | + | offset := dst.PixOffset(x, y) | |||
| 165 | + | inverse := uint16(255 - src.A) | |||
| 166 | + | dst.Pix[offset] = src.R + byte((uint16(dst.Pix[offset])*inverse+127)/255) | |||
| 167 | + | dst.Pix[offset+1] = src.G + byte((uint16(dst.Pix[offset+1])*inverse+127)/255) | |||
| 168 | + | dst.Pix[offset+2] = src.B + byte((uint16(dst.Pix[offset+2])*inverse+127)/255) | |||
| 169 | + | dst.Pix[offset+3] = src.A + byte((uint16(dst.Pix[offset+3])*inverse+127)/255) | |||
| 170 | + | } | |||
A internal/graphicsharness/render_test.go +104 -0
| @@ -0,0 +1,104 @@ | |||
| 1 | + | package graphicsharness | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | _ "embed" | |
| 6 | + | "encoding/base64" | |
| 7 | + | "image/color" | |
| 8 | + | "strings" | |
| 9 | + | "testing" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | vt "github.com/bnema/vev-vt" | |
| 14 | + | "github.com/bnema/vev-vt/graphics" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | //go:embed testdata/pngsuite-exif2c08.png.b64 | |
| 18 | + | var pngSuiteEXIFBase64 string | |
| 19 | + | ||
| 20 | + | //go:embed testdata/demo.apc | |
| 21 | + | var demoAPC []byte | |
| 22 | + | ||
| 23 | + | func TestRenderTerminalDemo(t *testing.T) { | |
| 24 | + | geometry := vt.Geometry{Cols: 4, Rows: 4, PixelWidth: 4, PixelHeight: 4} | |
| 25 | + | screen := vt.NewScreen(geometry.Cols, geometry.Rows) | |
| 26 | + | screen.SetGeometry(geometry) | |
| 27 | + | screen.Write(demoAPC) | |
| 28 | + | require.Equal(t, uint64(2), screen.GraphicsSnapshot().Usage().Assets) | |
| 29 | + | require.Equal(t, uint64(2), screen.GraphicsSnapshot().Usage().Placements) | |
| 30 | + | placements := screen.GraphicsSnapshot().Placements() | |
| 31 | + | require.Equal(t, graphics.PixelRect{X: 1, Y: 1, Width: 2, Height: 2}, placements[1].Destination()) | |
| 32 | + | require.Equal(t, graphics.PixelRect{Width: 2, Height: 2}, placements[1].Source()) | |
| 33 | + | require.Equal(t, int64(1), placements[1].Layer()) | |
| 34 | + | ||
| 35 | + | frame, err := RenderTerminal(demoAPC, geometry) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | require.Equal(t, color.RGBA{22, 62, 143, 255}, frame.RGBAAt(0, 0)) | |
| 38 | + | require.Equal(t, color.RGBA{168, 67, 106, 255}, frame.RGBAAt(1, 1)) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func TestRenderTerminalAPC(t *testing.T) { | |
| 42 | + | frame, err := RenderTerminal([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1;/wAA/w\x1b\\"), vt.Geometry{Cols: 1, Rows: 1, PixelWidth: 1, PixelHeight: 1}) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | require.Equal(t, color.RGBA{255, 0, 0, 255}, frame.RGBAAt(0, 0)) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestRenderDecodesMetadataBearingPNG(t *testing.T) { | |
| 48 | + | encoded, err := base64.StdEncoding.DecodeString(pngSuiteEXIFBase64) | |
| 49 | + | require.NoError(t, err) | |
| 50 | + | require.True(t, bytes.Contains(encoded, []byte("eXIf")), "fixture must retain its eXIf metadata chunk") | |
| 51 | + | ||
| 52 | + | input := []byte("\x1b_Ga=T,i=1,f=100;" + strings.ReplaceAll(pngSuiteEXIFBase64, "\n", "") + "\x1b\\") | |
| 53 | + | frame, err := RenderTerminal(input, vt.Geometry{Cols: 32, Rows: 32, PixelWidth: 32, PixelHeight: 32}) | |
| 54 | + | require.NoError(t, err) | |
| 55 | + | require.Equal(t, 32, frame.Bounds().Dx()) | |
| 56 | + | require.Equal(t, 32, frame.Bounds().Dy()) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestRenderScalesCropsAndClips(t *testing.T) { | |
| 60 | + | scene := graphics.NewScene(graphics.Limits{}) | |
| 61 | + | assetID, err := scene.AddAsset(graphics.AssetBlob{ | |
| 62 | + | Format: graphics.AssetFormatRGBA, | |
| 63 | + | Width: 3, | |
| 64 | + | Height: 2, | |
| 65 | + | Encoded: []byte{ | |
| 66 | + | 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, | |
| 67 | + | 0, 255, 255, 255, 255, 0, 255, 255, 255, 255, 0, 255, | |
| 68 | + | }, | |
| 69 | + | }) | |
| 70 | + | require.NoError(t, err) | |
| 71 | + | _, err = scene.Place(graphics.PlacementSpec{ | |
| 72 | + | Asset: assetID, | |
| 73 | + | Source: graphics.PixelRect{X: 1, Width: 2, Height: 2}, | |
| 74 | + | Destination: graphics.PixelRect{X: -1, Y: 1, Width: 5, Height: 2}, | |
| 75 | + | }) | |
| 76 | + | require.NoError(t, err) | |
| 77 | + | ||
| 78 | + | frame, err := Render(scene.Snapshot(), graphics.PixelRect{Width: 4, Height: 4}) | |
| 79 | + | require.NoError(t, err) | |
| 80 | + | ||
| 81 | + | for x, want := range []color.RGBA{{0, 255, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255}, {0, 0, 255, 255}} { | |
| 82 | + | require.Equal(t, want, frame.RGBAAt(x, 1)) | |
| 83 | + | } | |
| 84 | + | for x, want := range []color.RGBA{{255, 0, 255, 255}, {255, 0, 255, 255}, {255, 255, 0, 255}, {255, 255, 0, 255}} { | |
| 85 | + | require.Equal(t, want, frame.RGBAAt(x, 2)) | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestRenderCompositesPlacementsInLayerOrder(t *testing.T) { | |
| 90 | + | scene := graphics.NewScene(graphics.Limits{}) | |
| 91 | + | background, err := scene.AddAsset(graphics.AssetBlob{Format: graphics.AssetFormatRGBA, Width: 2, Height: 1, Encoded: []byte{0, 0, 255, 255, 0, 0, 255, 255}}) | |
| 92 | + | require.NoError(t, err) | |
| 93 | + | foreground, err := scene.AddAsset(graphics.AssetBlob{Format: graphics.AssetFormatRGBA, Width: 1, Height: 1, Encoded: []byte{255, 0, 0, 128}}) | |
| 94 | + | require.NoError(t, err) | |
| 95 | + | _, err = scene.Place(graphics.PlacementSpec{Asset: background, Destination: graphics.PixelRect{Width: 2, Height: 1}, HasLayer: true}) | |
| 96 | + | require.NoError(t, err) | |
| 97 | + | _, err = scene.Place(graphics.PlacementSpec{Asset: foreground, Destination: graphics.PixelRect{X: 1, Width: 1, Height: 1}, Layer: 1, HasLayer: true}) | |
| 98 | + | require.NoError(t, err) | |
| 99 | + | ||
| 100 | + | frame, err := Render(scene.Snapshot(), graphics.PixelRect{Width: 2, Height: 1}) | |
| 101 | + | require.NoError(t, err) | |
| 102 | + | require.Equal(t, color.RGBA{0, 0, 255, 255}, frame.RGBAAt(0, 0)) | |
| 103 | + | require.Equal(t, color.RGBA{128, 0, 127, 255}, frame.RGBAAt(1, 0)) | |
| 104 | + | } | |
| @@ -0,0 +1,104 @@ | |||||
| 1 | + | package graphicsharness | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | _ "embed" | |||
| 6 | + | "encoding/base64" | |||
| 7 | + | "image/color" | |||
| 8 | + | "strings" | |||
| 9 | + | "testing" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | vt "github.com/bnema/vev-vt" | |||
| 14 | + | "github.com/bnema/vev-vt/graphics" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | //go:embed testdata/pngsuite-exif2c08.png.b64 | |||
| 18 | + | var pngSuiteEXIFBase64 string | |||
| 19 | + | ||||
| 20 | + | //go:embed testdata/demo.apc | |||
| 21 | + | var demoAPC []byte | |||
| 22 | + | ||||
| 23 | + | func TestRenderTerminalDemo(t *testing.T) { | |||
| 24 | + | geometry := vt.Geometry{Cols: 4, Rows: 4, PixelWidth: 4, PixelHeight: 4} | |||
| 25 | + | screen := vt.NewScreen(geometry.Cols, geometry.Rows) | |||
| 26 | + | screen.SetGeometry(geometry) | |||
| 27 | + | screen.Write(demoAPC) | |||
| 28 | + | require.Equal(t, uint64(2), screen.GraphicsSnapshot().Usage().Assets) | |||
| 29 | + | require.Equal(t, uint64(2), screen.GraphicsSnapshot().Usage().Placements) | |||
| 30 | + | placements := screen.GraphicsSnapshot().Placements() | |||
| 31 | + | require.Equal(t, graphics.PixelRect{X: 1, Y: 1, Width: 2, Height: 2}, placements[1].Destination()) | |||
| 32 | + | require.Equal(t, graphics.PixelRect{Width: 2, Height: 2}, placements[1].Source()) | |||
| 33 | + | require.Equal(t, int64(1), placements[1].Layer()) | |||
| 34 | + | ||||
| 35 | + | frame, err := RenderTerminal(demoAPC, geometry) | |||
| 36 | + | require.NoError(t, err) | |||
| 37 | + | require.Equal(t, color.RGBA{22, 62, 143, 255}, frame.RGBAAt(0, 0)) | |||
| 38 | + | require.Equal(t, color.RGBA{168, 67, 106, 255}, frame.RGBAAt(1, 1)) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func TestRenderTerminalAPC(t *testing.T) { | |||
| 42 | + | frame, err := RenderTerminal([]byte("\x1b_Ga=T,i=1,f=32,s=1,v=1;/wAA/w\x1b\\"), vt.Geometry{Cols: 1, Rows: 1, PixelWidth: 1, PixelHeight: 1}) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | require.Equal(t, color.RGBA{255, 0, 0, 255}, frame.RGBAAt(0, 0)) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestRenderDecodesMetadataBearingPNG(t *testing.T) { | |||
| 48 | + | encoded, err := base64.StdEncoding.DecodeString(pngSuiteEXIFBase64) | |||
| 49 | + | require.NoError(t, err) | |||
| 50 | + | require.True(t, bytes.Contains(encoded, []byte("eXIf")), "fixture must retain its eXIf metadata chunk") | |||
| 51 | + | ||||
| 52 | + | input := []byte("\x1b_Ga=T,i=1,f=100;" + strings.ReplaceAll(pngSuiteEXIFBase64, "\n", "") + "\x1b\\") | |||
| 53 | + | frame, err := RenderTerminal(input, vt.Geometry{Cols: 32, Rows: 32, PixelWidth: 32, PixelHeight: 32}) | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | require.Equal(t, 32, frame.Bounds().Dx()) | |||
| 56 | + | require.Equal(t, 32, frame.Bounds().Dy()) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestRenderScalesCropsAndClips(t *testing.T) { | |||
| 60 | + | scene := graphics.NewScene(graphics.Limits{}) | |||
| 61 | + | assetID, err := scene.AddAsset(graphics.AssetBlob{ | |||
| 62 | + | Format: graphics.AssetFormatRGBA, | |||
| 63 | + | Width: 3, | |||
| 64 | + | Height: 2, | |||
| 65 | + | Encoded: []byte{ | |||
| 66 | + | 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, | |||
| 67 | + | 0, 255, 255, 255, 255, 0, 255, 255, 255, 255, 0, 255, | |||
| 68 | + | }, | |||
| 69 | + | }) | |||
| 70 | + | require.NoError(t, err) | |||
| 71 | + | _, err = scene.Place(graphics.PlacementSpec{ | |||
| 72 | + | Asset: assetID, | |||
| 73 | + | Source: graphics.PixelRect{X: 1, Width: 2, Height: 2}, | |||
| 74 | + | Destination: graphics.PixelRect{X: -1, Y: 1, Width: 5, Height: 2}, | |||
| 75 | + | }) | |||
| 76 | + | require.NoError(t, err) | |||
| 77 | + | ||||
| 78 | + | frame, err := Render(scene.Snapshot(), graphics.PixelRect{Width: 4, Height: 4}) | |||
| 79 | + | require.NoError(t, err) | |||
| 80 | + | ||||
| 81 | + | for x, want := range []color.RGBA{{0, 255, 0, 255}, {0, 255, 0, 255}, {0, 0, 255, 255}, {0, 0, 255, 255}} { | |||
| 82 | + | require.Equal(t, want, frame.RGBAAt(x, 1)) | |||
| 83 | + | } | |||
| 84 | + | for x, want := range []color.RGBA{{255, 0, 255, 255}, {255, 0, 255, 255}, {255, 255, 0, 255}, {255, 255, 0, 255}} { | |||
| 85 | + | require.Equal(t, want, frame.RGBAAt(x, 2)) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestRenderCompositesPlacementsInLayerOrder(t *testing.T) { | |||
| 90 | + | scene := graphics.NewScene(graphics.Limits{}) | |||
| 91 | + | background, err := scene.AddAsset(graphics.AssetBlob{Format: graphics.AssetFormatRGBA, Width: 2, Height: 1, Encoded: []byte{0, 0, 255, 255, 0, 0, 255, 255}}) | |||
| 92 | + | require.NoError(t, err) | |||
| 93 | + | foreground, err := scene.AddAsset(graphics.AssetBlob{Format: graphics.AssetFormatRGBA, Width: 1, Height: 1, Encoded: []byte{255, 0, 0, 128}}) | |||
| 94 | + | require.NoError(t, err) | |||
| 95 | + | _, err = scene.Place(graphics.PlacementSpec{Asset: background, Destination: graphics.PixelRect{Width: 2, Height: 1}, HasLayer: true}) | |||
| 96 | + | require.NoError(t, err) | |||
| 97 | + | _, err = scene.Place(graphics.PlacementSpec{Asset: foreground, Destination: graphics.PixelRect{X: 1, Width: 1, Height: 1}, Layer: 1, HasLayer: true}) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | ||||
| 100 | + | frame, err := Render(scene.Snapshot(), graphics.PixelRect{Width: 2, Height: 1}) | |||
| 101 | + | require.NoError(t, err) | |||
| 102 | + | require.Equal(t, color.RGBA{0, 0, 255, 255}, frame.RGBAAt(0, 0)) | |||
| 103 | + | require.Equal(t, color.RGBA{128, 0, 127, 255}, frame.RGBAAt(1, 0)) | |||
| 104 | + | } | |||
A internal/graphicsharness/terminal.go +21 -0
| @@ -0,0 +1,21 @@ | |||
| 1 | + | package graphicsharness | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "image" | |
| 6 | + | ||
| 7 | + | vt "github.com/bnema/vev-vt" | |
| 8 | + | "github.com/bnema/vev-vt/graphics" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // RenderTerminal feeds terminal bytes into a fresh screen and composites its | |
| 12 | + | // active graphics snapshot using geometry's pixel viewport. | |
| 13 | + | func RenderTerminal(input []byte, geometry vt.Geometry) (*image.RGBA, error) { | |
| 14 | + | if !geometry.Valid() || !geometry.PixelsKnown() { | |
| 15 | + | return nil, fmt.Errorf("render terminal: complete cell and pixel geometry is required") | |
| 16 | + | } | |
| 17 | + | screen := vt.NewScreen(geometry.Cols, geometry.Rows) | |
| 18 | + | screen.SetGeometry(geometry) | |
| 19 | + | screen.Write(input) | |
| 20 | + | return Render(screen.GraphicsSnapshot(), graphics.PixelRect{Width: int64(geometry.PixelWidth), Height: int64(geometry.PixelHeight)}) | |
| 21 | + | } | |
| @@ -0,0 +1,21 @@ | |||||
| 1 | + | package graphicsharness | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "image" | |||
| 6 | + | ||||
| 7 | + | vt "github.com/bnema/vev-vt" | |||
| 8 | + | "github.com/bnema/vev-vt/graphics" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // RenderTerminal feeds terminal bytes into a fresh screen and composites its | |||
| 12 | + | // active graphics snapshot using geometry's pixel viewport. | |||
| 13 | + | func RenderTerminal(input []byte, geometry vt.Geometry) (*image.RGBA, error) { | |||
| 14 | + | if !geometry.Valid() || !geometry.PixelsKnown() { | |||
| 15 | + | return nil, fmt.Errorf("render terminal: complete cell and pixel geometry is required") | |||
| 16 | + | } | |||
| 17 | + | screen := vt.NewScreen(geometry.Cols, geometry.Rows) | |||
| 18 | + | screen.SetGeometry(geometry) | |||
| 19 | + | screen.Write(input) | |||
| 20 | + | return Render(screen.GraphicsSnapshot(), graphics.PixelRect{Width: int64(geometry.PixelWidth), Height: int64(geometry.PixelHeight)}) | |||
| 21 | + | } | |||
A internal/graphicsharness/testdata/README.md +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | # Graphics harness fixtures | |
| 2 | + | ||
| 3 | + | `pngsuite-exif2c08.png.b64` is the Base64 representation of PNGSuite's | |
| 4 | + | `exif2c08.png`: a 32×32 truecolour PNG carrying an `eXIf` ancillary chunk. It | |
| 5 | + | is stored as text so fixtures remain inspectable in ordinary diffs; tests decode | |
| 6 | + | it before handing it to the PNG decoder. | |
| 7 | + | ||
| 8 | + | Source: [`lunapaint/pngsuite`](https://github.com/lunapaint/pngsuite) commit | |
| 9 | + | `8cd768dd0d0063195174d0d01cacbd5a7d1e5605`, blob | |
| 10 | + | `56eb734991497525b489b9baaf275d2e71d25683`. Decoded SHA-256: | |
| 11 | + | `d04140d74bc60597c47b5aac371a3553d3add9354437bbd8c57007d94e197809`. | |
| 12 | + | ||
| 13 | + | Copyright © Willem van Schaik, 1996, 2011. PNGSuite grants permission to use, | |
| 14 | + | copy, modify, and distribute these images for any purpose and without fee. The | |
| 15 | + | source license text is retained below. | |
| 16 | + | ||
| 17 | + | ```text | |
| 18 | + | PngSuite | |
| 19 | + | -------- | |
| 20 | + | ||
| 21 | + | Permission to use, copy, modify and distribute these images for any | |
| 22 | + | purpose and without fee is hereby granted. | |
| 23 | + | ||
| 24 | + | (c) Willem van Schaik, 1996, 2011 | |
| 25 | + | ``` | |
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | # Graphics harness fixtures | |||
| 2 | + | ||||
| 3 | + | `pngsuite-exif2c08.png.b64` is the Base64 representation of PNGSuite's | |||
| 4 | + | `exif2c08.png`: a 32×32 truecolour PNG carrying an `eXIf` ancillary chunk. It | |||
| 5 | + | is stored as text so fixtures remain inspectable in ordinary diffs; tests decode | |||
| 6 | + | it before handing it to the PNG decoder. | |||
| 7 | + | ||||
| 8 | + | Source: [`lunapaint/pngsuite`](https://github.com/lunapaint/pngsuite) commit | |||
| 9 | + | `8cd768dd0d0063195174d0d01cacbd5a7d1e5605`, blob | |||
| 10 | + | `56eb734991497525b489b9baaf275d2e71d25683`. Decoded SHA-256: | |||
| 11 | + | `d04140d74bc60597c47b5aac371a3553d3add9354437bbd8c57007d94e197809`. | |||
| 12 | + | ||||
| 13 | + | Copyright © Willem van Schaik, 1996, 2011. PNGSuite grants permission to use, | |||
| 14 | + | copy, modify, and distribute these images for any purpose and without fee. The | |||
| 15 | + | source license text is retained below. | |||
| 16 | + | ||||
| 17 | + | ```text | |||
| 18 | + | PngSuite | |||
| 19 | + | -------- | |||
| 20 | + | ||||
| 21 | + | Permission to use, copy, modify and distribute these images for any | |||
| 22 | + | purpose and without fee is hereby granted. | |||
| 23 | + | ||||
| 24 | + | (c) Willem van Schaik, 1996, 2011 | |||
| 25 | + | ``` | |||
A internal/graphicsharness/testdata/demo.apc +1 -0
| @@ -0,0 +1,1 @@ | |||
| 1 | + | <U+001B>_Ga=T,i=1,f=32,s=4,v=4,C=1;Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/w<U+001B>\<U+001B>_Ga=T,i=2,f=32,s=2,v=2,X=1,Y=1,z=1,C=1;/0ZVoP9GVaD/RlWg/0ZVoA<U+001B>\⏎̸ | |
| @@ -0,0 +1,1 @@ | |||||
| 1 | + | <U+001B>_Ga=T,i=1,f=32,s=4,v=4,C=1;Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/xY+j/8WPo//Fj6P/w<U+001B>\<U+001B>_Ga=T,i=2,f=32,s=2,v=2,X=1,Y=1,z=1,C=1;/0ZVoP9GVaD/RlWg/0ZVoA<U+001B>\⏎̸ | |||
A internal/graphicsharness/testdata/pngsuite-exif2c08.png.b64 +40 -0
| @@ -0,0 +1,40 @@ | |||
| 1 | + | iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAD0mVYSWZNTQAq | |
| 2 | + | AAAACAAHARIAAwAAAAEAAQAAARoABQAAAAEAAABiARsABQAAAAEAAABqASgA | |
| 3 | + | AwAAAAEAAgAAAhMAAwAAAAEAAQAAgpgAAgAAABcAAAByh2kABAAAAAEAAACK | |
| 4 | + | AAAA3AAAAEgAAAABAAAASAAAAAEyMDE3IFdpbGxlbSB2YW4gU2NoYWlrAAAA | |
| 5 | + | BZAAAAcAAAAEMDIyMJEBAAcAAAAEAQIDAJKGAAcAAAAQAAAAzKAAAAcAAAAE | |
| 6 | + | MDEwMKABAAMAAAAB//8AAAAAAABBU0NJSQAAAFBuZ1N1aXRlAAYBAwADAAAA | |
| 7 | + | AQAGAAABGgAFAAAAAQAAASoBGwAFAAAAAQAAATIBKAADAAAAAQACAAACAQAE | |
| 8 | + | AAAAAQAAAToCAgAEAAAAAQAAApcAAAAAAAAASAAAAAEAAABIAAAAAf/Y/+AA | |
| 9 | + | EEpGSUYAAQEAAAEAAQAA/9sAQwADAgIDAgIDAwMDBAMDBAUIBQUEBAUKBwcG | |
| 10 | + | CAwKDAwLCgsLDQ4SEA0OEQ4LCxAWEBETFBUVFQwPFxgWFBgSFBUU/9sAQwED | |
| 11 | + | BAQFBAUJBQUJFA0LDRQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQU | |
| 12 | + | FBQUFBQUFBQUFBQUFBQUFBQU/8AAEQgACAAIAwEiAAIRAQMRAf/EAB8AAAEF | |
| 13 | + | AQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAAB | |
| 14 | + | fQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBka | |
| 15 | + | JSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6 | |
| 16 | + | g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS | |
| 17 | + | 09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEA | |
| 18 | + | AAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUh | |
| 19 | + | MQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1 | |
| 20 | + | Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJ | |
| 21 | + | ipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ | |
| 22 | + | 2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A+7EGoxTRqz3ySM6A | |
| 23 | + | uwITn7+fbkf04ooor+Y6k27M66VCLWrb+Z//2QC6iKqDAAAC5UlEQVRIib2W | |
| 24 | + | 3W8SQRDA+a/HEBONGqPGRNP4YNQ3EyUYTUqQKjbVBx5IpbRQwCscl+OA40NC | |
| 25 | + | kQbK5+HM7ma5u3K5WsBkc5ndvZnf7uzuzAQWC9hqC/wnwMUFGAaUy6INhzRo | |
| 26 | + | mqKraVCpQLsN4zFYFk1Np9Dp0CBOVauk7gMYjUih1QJddwPw22wSHm2hPJnA | |
| 27 | + | bEYCdnGw0aAv6l7XRdyoHcBlNFqrkdHLS+j1aB1IRRhO4Z64sDEAbhSFfl+4 | |
| 28 | + | y/8MvpkAKUdLtqA3JuHxsXCRZkAwBXfS5MxI2f0/IlfaOfztDcDxJ1mST1Va | |
| 29 | + | b6JE8luVVn0VgBu9CSBcJPlnm+RYTSigHNX+BYDO3TOok2hBZwiKATkV+szv | |
| 30 | + | SZ3GQxrJzwskd8ckt7uQ1yBUEFpFwwFIMPfyNp0zQESlie+a4y6iglEnvz/I | |
| 31 | + | QH8Ct1LwNCfODVXwdobzpHWgipstAWnnlQ3M5xBjK/3yS1jHe8KvB8o7JzTF | |
| 32 | + | /bNrLNXwoXFHfVVoWd2uN8BrgrcfDZq6naZvoeeYuqp1E0B9II4reASj2XoA | |
| 33 | + | e5MvyFrAfeall4qb7QWwt5nlB8D2nvl639wa4A17DRFjbYD9/kqdiSVOWN5R | |
| 34 | + | X4DdjuV7yMU/y+XYwRu7RdEqTT1kQemwswXAs7wIKfh9p20UgM/4lIWQR8dQ | |
| 35 | + | 1ukd3Duhw+dJAuNzrEKz8bNlzoizBx9XHHl09SFP5mRoj4WzEAsGOxmS9T6N | |
| 36 | + | KyrkNPjI8FEFsiUCyJi2X3Lk0dXXFH2Chl4z1ys9Uv7MlA9MGg/n3P8jAPPo | |
| 37 | + | J4XkFAvpMo96Auom3E1DME27QUCGhfRXZ54AdNqHwrVDBQKyzOKLnCgpyhrj | |
| 38 | + | HYFeWw2Q4GRTFCg8j3oWXvaiiNcQmI3lIXOLGKV5NcW7XBgMliWWP4Bfj5Xj | |
| 39 | + | 5+d0mLKOcpUa/Le1ALghLGRkJegqljYAQJnKGU10eR7lLwD/kXl0LQA6BMtT | |
| 40 | + | 2eUFK0/sMo9uvbr+CztK5Y3mPSskAAAAAElFTkSuQmCC | |
| @@ -0,0 +1,40 @@ | |||||
| 1 | + | iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAD0mVYSWZNTQAq | |||
| 2 | + | AAAACAAHARIAAwAAAAEAAQAAARoABQAAAAEAAABiARsABQAAAAEAAABqASgA | |||
| 3 | + | AwAAAAEAAgAAAhMAAwAAAAEAAQAAgpgAAgAAABcAAAByh2kABAAAAAEAAACK | |||
| 4 | + | AAAA3AAAAEgAAAABAAAASAAAAAEyMDE3IFdpbGxlbSB2YW4gU2NoYWlrAAAA | |||
| 5 | + | BZAAAAcAAAAEMDIyMJEBAAcAAAAEAQIDAJKGAAcAAAAQAAAAzKAAAAcAAAAE | |||
| 6 | + | MDEwMKABAAMAAAAB//8AAAAAAABBU0NJSQAAAFBuZ1N1aXRlAAYBAwADAAAA | |||
| 7 | + | AQAGAAABGgAFAAAAAQAAASoBGwAFAAAAAQAAATIBKAADAAAAAQACAAACAQAE | |||
| 8 | + | AAAAAQAAAToCAgAEAAAAAQAAApcAAAAAAAAASAAAAAEAAABIAAAAAf/Y/+AA | |||
| 9 | + | EEpGSUYAAQEAAAEAAQAA/9sAQwADAgIDAgIDAwMDBAMDBAUIBQUEBAUKBwcG | |||
| 10 | + | CAwKDAwLCgsLDQ4SEA0OEQ4LCxAWEBETFBUVFQwPFxgWFBgSFBUU/9sAQwED | |||
| 11 | + | BAQFBAUJBQUJFA0LDRQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQU | |||
| 12 | + | FBQUFBQUFBQUFBQUFBQUFBQU/8AAEQgACAAIAwEiAAIRAQMRAf/EAB8AAAEF | |||
| 13 | + | AQEBAQEBAAAAAAAAAAABAgMEBQYHCAkKC//EALUQAAIBAwMCBAMFBQQEAAAB | |||
| 14 | + | fQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBka | |||
| 15 | + | JSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6 | |||
| 16 | + | g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS | |||
| 17 | + | 09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+v/EAB8BAAMBAQEBAQEBAQEA | |||
| 18 | + | AAAAAAABAgMEBQYHCAkKC//EALURAAIBAgQEAwQHBQQEAAECdwABAgMRBAUh | |||
| 19 | + | MQYSQVEHYXETIjKBCBRCkaGxwQkjM1LwFWJy0QoWJDThJfEXGBkaJicoKSo1 | |||
| 20 | + | Njc4OTpDREVGR0hJSlNUVVZXWFlaY2RlZmdoaWpzdHV2d3h5eoKDhIWGh4iJ | |||
| 21 | + | ipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ | |||
| 22 | + | 2uLj5OXm5+jp6vLz9PX29/j5+v/aAAwDAQACEQMRAD8A+7EGoxTRqz3ySM6A | |||
| 23 | + | uwITn7+fbkf04ooor+Y6k27M66VCLWrb+Z//2QC6iKqDAAAC5UlEQVRIib2W | |||
| 24 | + | 3W8SQRDA+a/HEBONGqPGRNP4YNQ3EyUYTUqQKjbVBx5IpbRQwCscl+OA40NC | |||
| 25 | + | kQbK5+HM7ma5u3K5WsBkc5ndvZnf7uzuzAQWC9hqC/wnwMUFGAaUy6INhzRo | |||
| 26 | + | mqKraVCpQLsN4zFYFk1Np9Dp0CBOVauk7gMYjUih1QJddwPw22wSHm2hPJnA | |||
| 27 | + | bEYCdnGw0aAv6l7XRdyoHcBlNFqrkdHLS+j1aB1IRRhO4Z64sDEAbhSFfl+4 | |||
| 28 | + | y/8MvpkAKUdLtqA3JuHxsXCRZkAwBXfS5MxI2f0/IlfaOfztDcDxJ1mST1Va | |||
| 29 | + | b6JE8luVVn0VgBu9CSBcJPlnm+RYTSigHNX+BYDO3TOok2hBZwiKATkV+szv | |||
| 30 | + | SZ3GQxrJzwskd8ckt7uQ1yBUEFpFwwFIMPfyNp0zQESlie+a4y6iglEnvz/I | |||
| 31 | + | QH8Ct1LwNCfODVXwdobzpHWgipstAWnnlQ3M5xBjK/3yS1jHe8KvB8o7JzTF | |||
| 32 | + | /bNrLNXwoXFHfVVoWd2uN8BrgrcfDZq6naZvoeeYuqp1E0B9II4reASj2XoA | |||
| 33 | + | e5MvyFrAfeall4qb7QWwt5nlB8D2nvl639wa4A17DRFjbYD9/kqdiSVOWN5R | |||
| 34 | + | X4DdjuV7yMU/y+XYwRu7RdEqTT1kQemwswXAs7wIKfh9p20UgM/4lIWQR8dQ | |||
| 35 | + | 1ukd3Duhw+dJAuNzrEKz8bNlzoizBx9XHHl09SFP5mRoj4WzEAsGOxmS9T6N | |||
| 36 | + | KyrkNPjI8FEFsiUCyJi2X3Lk0dXXFH2Chl4z1ys9Uv7MlA9MGg/n3P8jAPPo | |||
| 37 | + | J4XkFAvpMo96Auom3E1DME27QUCGhfRXZ54AdNqHwrVDBQKyzOKLnCgpyhrj | |||
| 38 | + | HYFeWw2Q4GRTFCg8j3oWXvaiiNcQmI3lIXOLGKV5NcW7XBgMliWWP4Bfj5Xj | |||
| 39 | + | 5+d0mLKOcpUa/Le1ALghLGRkJegqljYAQJnKGU10eR7lLwD/kXl0LQA6BMtT | |||
| 40 | + | 2eUFK0/sMo9uvbr+CztK5Y3mPSskAAAAAElFTkSuQmCC | |||