Skip to content

Commit da2b1a4

Browse files
committed
feat: add image diffing with half-block terminal rendering
Add support for comparing image files (PNG, JPEG, GIF, TIFF, BMP, WebP) with pixel-level diff stats, side-by-side rendering using half-block Unicode characters with truecolor ANSI, and toggleable view modes (side-by-side, before, after, diff overlay) via the `v` key.
1 parent b2dd823 commit da2b1a4

File tree

18 files changed

+1320
-15
lines changed

18 files changed

+1320
-15
lines changed

cmd/drift/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ var Version = "dev"
1616
type cmd struct {
1717
PathA string `arg:"" help:"First path to compare."`
1818
PathB string `arg:"" help:"Second path to compare."`
19-
Mode string `short:"m" help:"Force comparison mode (tree, binary, plist, text)." default:""`
19+
Mode string `short:"m" help:"Force comparison mode (tree, binary, plist, text, image)." default:""`
2020
JSON bool `help:"Force JSON output."`
2121
Version kong.VersionFlag `help:"Print version and exit."`
2222
}

compare/compare.go

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const (
1616
ModeBinary Mode = "binary"
1717
ModePlist Mode = "plist"
1818
ModeText Mode = "text"
19+
ModeImage Mode = "image"
1920
)
2021

2122
// Compare runs the appropriate comparison for the given paths and mode.
@@ -41,8 +42,10 @@ func Compare(pathA, pathB, mode Mode) (*Result, error) {
4142
root, err = compareSingle(pathA, pathB, KindPlist)
4243
case ModeText:
4344
root, err = compareSingle(pathA, pathB, KindText)
45+
case ModeImage:
46+
root, err = compareSingle(pathA, pathB, KindImage)
4447
default:
45-
return nil, fmt.Errorf("unknown mode: %s (valid: tree, binary, plist, text)", mode)
48+
return nil, fmt.Errorf("unknown mode: %s (valid: tree, binary, plist, text, image)", mode)
4649
}
4750
if err != nil {
4851
return nil, err
@@ -218,6 +221,11 @@ func classifyPath(path string, isDir bool) FileKind {
218221
return KindMachO
219222
}
220223

224+
// Image files get their own kind for visual diffing.
225+
if isImageExt(ext) {
226+
return KindImage
227+
}
228+
221229
// Known binary/opaque data extensions.
222230
if isDataExt(ext) {
223231
return KindData
@@ -236,12 +244,21 @@ func classifyPath(path string, isDir bool) FileKind {
236244
return KindText
237245
}
238246

247+
// isImageExt returns true for image file extensions that drift can decode and diff.
248+
func isImageExt(ext string) bool {
249+
switch ext {
250+
case ".png", ".jpg", ".jpeg", ".gif", ".tiff", ".tif", ".bmp", ".webp":
251+
return true
252+
}
253+
return false
254+
}
255+
239256
// isDataExt returns true for extensions that are known binary/opaque data.
240257
func isDataExt(ext string) bool {
241258
switch ext {
242259
case ".car", ".nib", ".storyboardc", ".mom", ".momd", ".omo",
243-
".metallib", ".dat", ".db", ".sqlite", ".png", ".jpg", ".jpeg",
244-
".gif", ".icns", ".tiff", ".tif", ".pdf", ".ttf", ".otf",
260+
".metallib", ".dat", ".db", ".sqlite",
261+
".icns", ".pdf", ".ttf", ".otf",
245262
".woff", ".woff2", ".p12", ".cer", ".der", ".mobileprovision",
246263
".lproj", ".sig", ".bin", ".enc", ".bom", ".pak":
247264
return true
@@ -366,6 +383,9 @@ func detectMode(pathA, pathB string) (Mode, error) {
366383
if extA == ".plist" && extB == ".plist" {
367384
return ModePlist, nil
368385
}
386+
if isImageExt(extA) && isImageExt(extB) {
387+
return ModeImage, nil
388+
}
369389
if isMachO(pathA) && isMachO(pathB) {
370390
return ModeBinary, nil
371391
}

compare/detail.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ type DetailResult struct {
1111
Plist *PlistDiff `json:"plist,omitempty"`
1212
Binary *BinaryDiff `json:"binary,omitempty"`
1313
Text *TextDiff `json:"text,omitempty"`
14+
Image *ImageDiff `json:"image,omitempty"`
1415
Dir *DirSummary `json:"dir,omitempty"`
1516
}
1617

@@ -52,6 +53,12 @@ func Detail(result *Result, node *Node) (*DetailResult, error) {
5253
return nil, err
5354
}
5455
return &DetailResult{Kind: KindText, Text: diff}, nil
56+
case KindImage:
57+
diff, err := compareImage(result.PathA, result.PathB, node.Path, node.Status)
58+
if err != nil {
59+
return nil, err
60+
}
61+
return &DetailResult{Kind: KindImage, Image: diff}, nil
5562
case KindData:
5663
return &DetailResult{Kind: KindData}, nil
5764
default:

compare/image.go

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
package compare
2+
3+
import (
4+
"bytes"
5+
"fmt"
6+
"image"
7+
"image/color"
8+
9+
// Register decoders for image.Decode.
10+
_ "image/gif"
11+
_ "image/jpeg"
12+
_ "image/png"
13+
14+
_ "golang.org/x/image/bmp"
15+
_ "golang.org/x/image/tiff"
16+
_ "golang.org/x/image/webp"
17+
)
18+
19+
// ImageDiff holds the result of comparing two images.
20+
type ImageDiff struct {
21+
WidthA int `json:"width_a"`
22+
HeightA int `json:"height_a"`
23+
WidthB int `json:"width_b"`
24+
HeightB int `json:"height_b"`
25+
26+
FormatA string `json:"format_a"`
27+
FormatB string `json:"format_b"`
28+
29+
ColorModelA string `json:"color_model_a"`
30+
ColorModelB string `json:"color_model_b"`
31+
32+
// Pixel diff stats (only populated when dimensions match).
33+
PixelsChanged int `json:"pixels_changed"`
34+
PixelsTotal int `json:"pixels_total"`
35+
ChangePercent float64 `json:"change_percent"`
36+
37+
// Bounding box of changed region.
38+
ChangeBounds image.Rectangle `json:"change_bounds"`
39+
40+
// Decoded images for TUI rendering (not serialized).
41+
ImageA image.Image `json:"-"`
42+
ImageB image.Image `json:"-"`
43+
// DiffMask highlights changed pixels (nil when dimensions differ).
44+
DiffMask image.Image `json:"-"`
45+
}
46+
47+
// compareImage decodes two images and produces a diff.
48+
func compareImage(sourceA, sourceB, relPath string, status DiffStatus) (*ImageDiff, error) {
49+
diff := &ImageDiff{}
50+
51+
if status != Added {
52+
dataA, err := readContent(sourceA, relPath)
53+
if err != nil {
54+
return nil, fmt.Errorf("reading image A: %w", err)
55+
}
56+
imgA, fmtA, err := image.Decode(bytes.NewReader(dataA))
57+
if err != nil {
58+
return nil, fmt.Errorf("decoding image A: %w", err)
59+
}
60+
diff.ImageA = imgA
61+
diff.FormatA = fmtA
62+
diff.WidthA = imgA.Bounds().Dx()
63+
diff.HeightA = imgA.Bounds().Dy()
64+
diff.ColorModelA = colorModelName(imgA.ColorModel())
65+
}
66+
67+
if status != Removed {
68+
dataB, err := readContent(sourceB, relPath)
69+
if err != nil {
70+
return nil, fmt.Errorf("reading image B: %w", err)
71+
}
72+
imgB, fmtB, err := image.Decode(bytes.NewReader(dataB))
73+
if err != nil {
74+
return nil, fmt.Errorf("decoding image B: %w", err)
75+
}
76+
diff.ImageB = imgB
77+
diff.FormatB = fmtB
78+
diff.WidthB = imgB.Bounds().Dx()
79+
diff.HeightB = imgB.Bounds().Dy()
80+
diff.ColorModelB = colorModelName(imgB.ColorModel())
81+
}
82+
83+
// Compute pixel diff when both images exist and dimensions match.
84+
if diff.ImageA != nil && diff.ImageB != nil &&
85+
diff.WidthA == diff.WidthB && diff.HeightA == diff.HeightB {
86+
diff.computePixelDiff()
87+
}
88+
89+
return diff, nil
90+
}
91+
92+
// computePixelDiff compares images pixel-by-pixel and builds a diff mask.
93+
func (d *ImageDiff) computePixelDiff() {
94+
bounds := d.ImageA.Bounds()
95+
w, h := bounds.Dx(), bounds.Dy()
96+
d.PixelsTotal = w * h
97+
98+
mask := image.NewNRGBA(bounds)
99+
minX, minY := w, h
100+
maxX, maxY := 0, 0
101+
102+
diffPixel := color.NRGBA{R: 255, G: 60, B: 60, A: 200}
103+
104+
// Fast path: direct pixel slice access when both images are NRGBA.
105+
nrgbaA, okA := d.ImageA.(*image.NRGBA)
106+
nrgbaB, okB := d.ImageB.(*image.NRGBA)
107+
108+
for y := bounds.Min.Y; y < bounds.Max.Y; y++ {
109+
for x := bounds.Min.X; x < bounds.Max.X; x++ {
110+
var differs bool
111+
if okA && okB {
112+
iA := nrgbaA.PixOffset(x, y)
113+
iB := nrgbaB.PixOffset(x, y)
114+
differs = nrgbaA.Pix[iA] != nrgbaB.Pix[iB] ||
115+
nrgbaA.Pix[iA+1] != nrgbaB.Pix[iB+1] ||
116+
nrgbaA.Pix[iA+2] != nrgbaB.Pix[iB+2] ||
117+
nrgbaA.Pix[iA+3] != nrgbaB.Pix[iB+3]
118+
} else {
119+
rA, gA, bA, aA := d.ImageA.At(x, y).RGBA()
120+
rB, gB, bB, aB := d.ImageB.At(x, y).RGBA()
121+
differs = rA != rB || gA != gB || bA != bB || aA != aB
122+
}
123+
124+
if differs {
125+
d.PixelsChanged++
126+
mask.SetNRGBA(x, y, diffPixel)
127+
128+
if x < minX {
129+
minX = x
130+
}
131+
if x > maxX {
132+
maxX = x
133+
}
134+
if y < minY {
135+
minY = y
136+
}
137+
if y > maxY {
138+
maxY = y
139+
}
140+
}
141+
}
142+
}
143+
144+
if d.PixelsChanged > 0 {
145+
d.ChangePercent = float64(d.PixelsChanged) / float64(d.PixelsTotal) * 100
146+
d.ChangeBounds = image.Rect(minX, minY, maxX+1, maxY+1)
147+
}
148+
d.DiffMask = mask
149+
}
150+
151+
// colorModelName returns a human-readable name for an image color model.
152+
func colorModelName(m color.Model) string {
153+
switch m {
154+
case color.RGBAModel:
155+
return "RGBA"
156+
case color.RGBA64Model:
157+
return "RGBA64"
158+
case color.NRGBAModel:
159+
return "NRGBA"
160+
case color.NRGBA64Model:
161+
return "NRGBA64"
162+
case color.AlphaModel:
163+
return "Alpha"
164+
case color.Alpha16Model:
165+
return "Alpha16"
166+
case color.GrayModel:
167+
return "Gray"
168+
case color.Gray16Model:
169+
return "Gray16"
170+
case color.CMYKModel:
171+
return "CMYK"
172+
case color.YCbCrModel:
173+
return "YCbCr"
174+
default:
175+
return "unknown"
176+
}
177+
}

0 commit comments

Comments
 (0)