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.
4package graphicsharness
5
6import (
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
17const 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.
23func 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
57func 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
68func maxInt() int { return int(^uint(0) >> 1) }
69
70func 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 config, err := png.DecodeConfig(bytes.NewReader(encoded))
79 if err != nil {
80 return nil, fmt.Errorf("decode asset %s: %w", asset.ID(), err)
81 }
82 if config.Width != width || config.Height != height {
83 return nil, fmt.Errorf("decode asset %s: PNG dimensions do not match snapshot", asset.ID())
84 }
85 decoded, err := png.Decode(bytes.NewReader(encoded))
86 if err != nil {
87 return nil, fmt.Errorf("decode asset %s: %w", asset.ID(), err)
88 }
89 result := image.NewRGBA(image.Rect(0, 0, width, height))
90 for y := range height {
91 for x := range width {
92 result.Set(x, y, decoded.At(decoded.Bounds().Min.X+x, decoded.Bounds().Min.Y+y))
93 }
94 }
95 return result, nil
96 case graphics.AssetFormatRGB, graphics.AssetFormatRGBA:
97 channels := 3
98 if asset.Format() == graphics.AssetFormatRGBA {
99 channels = 4
100 }
101 expected := uint64(width) * uint64(height) * uint64(channels)
102 if uint64(len(encoded)) != expected {
103 return nil, fmt.Errorf("decode asset %s: raw payload has %d bytes, want %d", asset.ID(), len(encoded), expected)
104 }
105 result := image.NewRGBA(image.Rect(0, 0, width, height))
106 for y := range height {
107 for x := range width {
108 offset := (y*width + x) * channels
109 r, g, b, a := encoded[offset], encoded[offset+1], encoded[offset+2], byte(255)
110 if channels == 4 {
111 a = encoded[offset+3]
112 }
113 result.SetRGBA(x, y, color.RGBA{R: premultiply(r, a), G: premultiply(g, a), B: premultiply(b, a), A: a})
114 }
115 }
116 return result, nil
117 default:
118 return nil, fmt.Errorf("decode asset %s: unsupported format %d", asset.ID(), asset.Format())
119 }
120}
121
122func composite(frame *image.RGBA, viewport graphics.PixelRect, source *image.RGBA, sourceRect, destination graphics.PixelRect) error {
123 visible, ok := destination.Intersect(viewport)
124 if !ok {
125 return nil
126 }
127 for y := visible.Y; y < visible.Y+visible.Height; y++ {
128 sy, ok := scaledCoordinate(y, destination.Y, destination.Height, sourceRect.Y, sourceRect.Height)
129 if !ok {
130 return fmt.Errorf("invalid vertical mapping")
131 }
132 for x := visible.X; x < visible.X+visible.Width; x++ {
133 sx, ok := scaledCoordinate(x, destination.X, destination.Width, sourceRect.X, sourceRect.Width)
134 if !ok || sx < 0 || sy < 0 || sx >= int64(source.Bounds().Dx()) || sy >= int64(source.Bounds().Dy()) {
135 return fmt.Errorf("invalid source mapping")
136 }
137 dx, dy := int(x-viewport.X), int(y-viewport.Y)
138 blend(frame, dx, dy, source.RGBAAt(int(sx), int(sy)))
139 }
140 }
141 return nil
142}
143
144func scaledCoordinate(value, destinationStart, destinationSize, sourceStart, sourceSize int64) (int64, bool) {
145 if destinationSize <= 0 || sourceSize <= 0 || value < destinationStart {
146 return 0, false
147 }
148 offset := value - destinationStart
149 if offset >= destinationSize {
150 return 0, false
151 }
152 hi, lo := bits.Mul64(uint64(offset), uint64(sourceSize))
153 if hi >= uint64(destinationSize) {
154 return 0, false
155 }
156 quotient, _ := bits.Div64(hi, lo, uint64(destinationSize))
157 if quotient > uint64(^uint64(0)>>1) || sourceStart > int64(^uint64(0)>>1)-int64(quotient) {
158 return 0, false
159 }
160 return sourceStart + int64(quotient), true
161}
162
163func premultiply(component, alpha byte) byte {
164 return byte((uint16(component)*uint16(alpha) + 127) / 255)
165}
166
167func blend(dst *image.RGBA, x, y int, src color.RGBA) {
168 offset := dst.PixOffset(x, y)
169 inverse := uint16(255 - src.A)
170 dst.Pix[offset] = src.R + byte((uint16(dst.Pix[offset])*inverse+127)/255)
171 dst.Pix[offset+1] = src.G + byte((uint16(dst.Pix[offset+1])*inverse+127)/255)
172 dst.Pix[offset+2] = src.B + byte((uint16(dst.Pix[offset+2])*inverse+127)/255)
173 dst.Pix[offset+3] = src.A + byte((uint16(dst.Pix[offset+3])*inverse+127)/255)
174}