Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 24 additions & 8 deletions src/sentry/preprod/snapshots/image_diff/compare.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from PIL import Image

from .odiff import OdiffServer
from .odiff import OdiffServer, compare_cli
from .types import DiffResult

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -79,13 +79,17 @@ def compare_images_batch(
tmpdir_path = Path(tmpdir)
if server is not None:
return _compare_pairs(pairs, server, tmpdir_path)
with OdiffServer() as new_server:
return _compare_pairs(pairs, new_server, tmpdir_path)
# Temporarily skip creating OdiffServer due to server-mode stdin
# buffer reuse bug (https://github.com/dmtrKovalenko/odiff/pull/170).
# Revert once the fix is released in odiff-bin.
# with OdiffServer() as new_server:
# return _compare_pairs(pairs, new_server, tmpdir_path)
return _compare_pairs(pairs, None, tmpdir_path)


def _compare_pairs(
pairs: Sequence[tuple[bytes | Image.Image, bytes | Image.Image]],
server: OdiffServer,
server: OdiffServer | None,
tmpdir_path: Path,
) -> list[DiffResult | None]:
return [
Expand All @@ -98,7 +102,7 @@ def _compare_single_pair(
idx: int,
before: bytes | Image.Image,
after: bytes | Image.Image,
server: OdiffServer,
server: OdiffServer | None,
tmpdir_path: Path,
) -> DiffResult | None:
before_img: Image.Image | None = None
Expand All @@ -123,23 +127,35 @@ def _compare_single_pair(
after_padded.save(after_path, "PNG")

output_path = tmpdir_path / f"diff_{idx}.png"
resp = server.compare(
# Use CLI mode instead of server mode to work around stdin buffer
# reuse bug (https://github.com/dmtrKovalenko/odiff/pull/170).
# Revert to server.compare() once the fix is released in odiff-bin.
# resp = server.compare(
# before_path,
# after_path,
# output_path,
# threshold=ODIFF_SENSITIVITY_DIFF_THRESHOLD,
# antialiasing=True,
# outputDiffMask=True,
# )
resp = compare_cli(
before_path,
after_path,
output_path,
threshold=ODIFF_SENSITIVITY_DIFF_THRESHOLD,
antialiasing=True,
outputDiffMask=True,
)
changed_pixels = resp.diffCount or 0
total_pixels = max_w * max_h

if changed_pixels == 0:
if resp.match:
changed_pixels = 0
diff_mask = Image.new("L", (max_w, max_h), 0)
elif not output_path.exists():
raise RuntimeError(f"odiff did not produce output file: {output_path}")
else:
diff_mask = _mask_from_diff_output(output_path)
changed_pixels = sum(diff_mask.histogram()[1:])

diff_mask_png = _encode_mask_png(diff_mask)

Expand Down
40 changes: 40 additions & 0 deletions src/sentry/preprod/snapshots/image_diff/odiff.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
from sentry.preprod.snapshots.image_diff.types import OdiffResponse
from sentry.utils import json

_CLI_FLAG_MAP = {
"outputDiffMask": "diff-mask",
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incorrect CLI flag name mapping for diff mask

High Severity

The _CLI_FLAG_MAP maps "outputDiffMask" to "diff-mask", producing CLI flag --diff-mask. The actual odiff CLI flag is --output-diff-mask. The standard camelCase-to-kebab-case conversion of outputDiffMask yields output-diff-mask, but the map incorrectly drops the output- prefix. This causes odiff to receive an unrecognized flag, likely making every comparison fail with a RuntimeError (non-zero exit code not in the allowed set {0, 21, 22}).

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit d075061. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wrong, i intentionally changed this (and verified this is the correct setup)


ODIFF_PLATFORM_SUFFIXES = {
("arm64", "Darwin"): "macos-arm64",
("aarch64", "Darwin"): "macos-arm64",
Expand Down Expand Up @@ -38,6 +42,42 @@ def _find_odiff_binary() -> str:
raise FileNotFoundError("odiff binary not found. Run 'pnpm install' to install odiff-bin.")


def compare_cli(
base_path: str | Path,
compare_path: str | Path,
output_path: str | Path,
**options: object,
) -> OdiffResponse:
"""Run a single odiff comparison via CLI (no server mode).

Workaround for server-mode stdin buffer reuse bug
(https://github.com/dmtrKovalenko/odiff/pull/170).
Remove once the fix is released in odiff-bin.
"""
binary = _find_odiff_binary()
cmd = [binary, str(base_path), str(compare_path), str(output_path)]

for key, value in options.items():
flag = _CLI_FLAG_MAP.get(key, key)
if isinstance(value, bool):
if value:
cmd.append(f"--{flag}")
else:
cmd.extend([f"--{flag}", str(value)])

result = subprocess.run(cmd, capture_output=True, timeout=30)

# Exit codes: 0=match, 21=layout diff, 22=pixel diff
if result.returncode not in (0, 21, 22):
stderr = result.stderr.decode(errors="replace").strip()
raise RuntimeError(f"odiff CLI error (exit {result.returncode}): {stderr}")

return OdiffResponse(
requestId=0,
match=(result.returncode == 0),
)


class OdiffServer:
def __init__(self) -> None:
self._process: subprocess.Popen[bytes] | None = None
Expand Down
Loading
Loading