Skip to content

Commit 91e5b63

Browse files
committed
Compress screenshot previews in PR comments
1 parent 896c0e0 commit 91e5b63

File tree

2 files changed

+96
-7
lines changed

2 files changed

+96
-7
lines changed

scripts/android/tests/process_screenshots.py

Lines changed: 95 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,13 @@
77
import base64
88
import json
99
import pathlib
10+
import struct
1011
import sys
1112
import zlib
1213
from dataclasses import dataclass
13-
from typing import Dict, List, Tuple
14+
from typing import Dict, Iterable, List, Tuple
15+
16+
MAX_COMMENT_BASE64 = 40_000
1417

1518
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
1619

@@ -162,9 +165,7 @@ def load_png(path: pathlib.Path) -> PNGImage:
162165
return PNGImage(width, height, bit_depth, color_type, pixels, bpp)
163166

164167

165-
def compare_images(expected_path: pathlib.Path, actual_path: pathlib.Path) -> Dict[str, bool]:
166-
expected = load_png(expected_path)
167-
actual = load_png(actual_path)
168+
def compare_images(expected: PNGImage, actual: PNGImage) -> Dict[str, bool]:
168169
equal = (
169170
expected.width == actual.width
170171
and expected.height == actual.height
@@ -181,6 +182,85 @@ def compare_images(expected_path: pathlib.Path, actual_path: pathlib.Path) -> Di
181182
}
182183

183184

185+
def _encode_png(width: int, height: int, bit_depth: int, color_type: int, bpp: int, pixels: bytes) -> bytes:
186+
import zlib as _zlib
187+
188+
if len(pixels) != width * height * bpp:
189+
raise PNGError("Pixel buffer length does not match dimensions")
190+
191+
def chunk(tag: bytes, payload: bytes) -> bytes:
192+
crc = _zlib.crc32(tag + payload) & 0xFFFFFFFF
193+
return (
194+
len(payload).to_bytes(4, "big")
195+
+ tag
196+
+ payload
197+
+ crc.to_bytes(4, "big")
198+
)
199+
200+
raw = bytearray()
201+
stride = width * bpp
202+
for row in range(height):
203+
raw.append(0)
204+
start = row * stride
205+
raw.extend(pixels[start : start + stride])
206+
207+
ihdr = struct.pack(
208+
">IIBBBBB",
209+
width,
210+
height,
211+
bit_depth,
212+
color_type,
213+
0,
214+
0,
215+
0,
216+
)
217+
218+
compressed = _zlib.compress(bytes(raw))
219+
return b"".join(
220+
[PNG_SIGNATURE, chunk(b"IHDR", ihdr), chunk(b"IDAT", compressed), chunk(b"IEND", b"")]
221+
)
222+
223+
224+
def _downscale_half(width: int, height: int, bpp: int, pixels: bytes) -> Tuple[int, int, bytes]:
225+
new_width = max(1, (width + 1) // 2)
226+
new_height = max(1, (height + 1) // 2)
227+
new_pixels = bytearray(new_width * new_height * bpp)
228+
229+
for ny in range(new_height):
230+
for nx in range(new_width):
231+
accum = [0] * bpp
232+
samples = 0
233+
for dy in (0, 1):
234+
sy = min(height - 1, ny * 2 + dy)
235+
for dx in (0, 1):
236+
sx = min(width - 1, nx * 2 + dx)
237+
src_index = (sy * width + sx) * bpp
238+
for channel in range(bpp):
239+
accum[channel] += pixels[src_index + channel]
240+
samples += 1
241+
dst_index = (ny * new_width + nx) * bpp
242+
for channel in range(bpp):
243+
new_pixels[dst_index + channel] = accum[channel] // samples
244+
245+
return new_width, new_height, bytes(new_pixels)
246+
247+
248+
def build_preview_base64(image: PNGImage, max_length: int = MAX_COMMENT_BASE64) -> str:
249+
width = image.width
250+
height = image.height
251+
bpp = image.bytes_per_pixel
252+
pixels = image.pixels
253+
254+
while True:
255+
png_bytes = _encode_png(width, height, image.bit_depth, image.color_type, bpp, pixels)
256+
encoded = base64.b64encode(png_bytes).decode("ascii")
257+
if len(encoded) <= max_length or width <= 1 or height <= 1:
258+
return encoded
259+
if image.color_type not in {0, 2, 4, 6}:
260+
return encoded
261+
width, height, pixels = _downscale_half(width, height, bpp, pixels)
262+
263+
184264
def build_results(reference_dir: pathlib.Path, actual_entries: List[Tuple[str, pathlib.Path]], emit_base64: bool) -> Dict[str, List[Dict[str, object]]]:
185265
results: List[Dict[str, object]] = []
186266
for test_name, actual_path in actual_entries:
@@ -195,10 +275,15 @@ def build_results(reference_dir: pathlib.Path, actual_entries: List[Tuple[str, p
195275
elif not expected_path.exists():
196276
record.update({"status": "missing_expected"})
197277
if emit_base64:
198-
record["base64"] = base64.b64encode(actual_path.read_bytes()).decode("ascii")
278+
try:
279+
record["base64"] = build_preview_base64(load_png(actual_path))
280+
except Exception:
281+
record["base64"] = base64.b64encode(actual_path.read_bytes()).decode("ascii")
199282
else:
200283
try:
201-
outcome = compare_images(expected_path, actual_path)
284+
actual_img = load_png(actual_path)
285+
expected_img = load_png(expected_path)
286+
outcome = compare_images(expected_img, actual_img)
202287
except Exception as exc:
203288
record.update({"status": "error", "message": str(exc)})
204289
else:
@@ -207,7 +292,10 @@ def build_results(reference_dir: pathlib.Path, actual_entries: List[Tuple[str, p
207292
else:
208293
record.update({"status": "different", "details": outcome})
209294
if emit_base64:
210-
record["base64"] = base64.b64encode(actual_path.read_bytes()).decode("ascii")
295+
try:
296+
record["base64"] = build_preview_base64(actual_img)
297+
except Exception:
298+
record["base64"] = base64.b64encode(actual_path.read_bytes()).decode("ascii")
211299
results.append(record)
212300
return {"results": results}
213301

scripts/run-android-instrumentation-tests.sh

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,7 @@ if comment_entries:
485485
if entry.get("base64"):
486486
lines.append("")
487487
lines.append(f" ![{entry['test']}](data:image/png;base64,{entry['base64']})")
488+
lines.append(" _(Preview image scaled for comment delivery.)_")
488489
lines.append("")
489490
comment_path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
490491
else:

0 commit comments

Comments
 (0)