---
title: "aadhar.sh/garage/ig-prep: Pixels Instagram has no reason to resample"
description: "A converter that performs the 40 megapixel downscale in linear light and delivers the exact width Instagram wants, plus an MCP mode that lets a model run it against photographs that stay on your machine."
path: "/garage/ig-prep"
section: "garage"
kind: "content"
updated: "2026-08-24"
source: "https://aadhar.sh/garage/ig-prep"
---

> Site index: https://aadhar.sh/llms.txt
> Section index: https://aadhar.sh/garage/llms.txt
> This is the Markdown twin of a page on aadhar.sh. The HTML at the source
> URL below is the original, and is hand-written and unminified on purpose.

# Handing Instagram pixels it has no reason to resample

Instagram resizes and re-encodes whatever it is given. What it does not do is resample a frame that already arrives at the size it wants. [ig-prep](https://github.com/oddharsh/ig-prep) does the destructive work here, carefully, so the platform has as little left to do as possible. It also speaks [MCP](https://modelcontextprotocol.io), so a model can run it against photographs on your own machine without any of them moving.

## The geometric fact the default rests on

Instagram shows frames between 4:5 and 1.91:1. A 3:2 portrait, which is most of what a Fujifilm or a Leica produces held vertically, is 0.667 and falls outside that band, so something has to give.

The default gives nothing up, because **cropping a portrait to 4:5 removes height only**. A frame delivered at exactly the target width is already the right width for any vertical crop you drag in the app, so the crop costs no resample at all. You keep the framing decision. The expensive step, a 40 megapixel downscale in linear light with Lanczos3, stays here where it is done once and done carefully.

One thing breaks it: pinch-zooming in the crop UI changes the scale and hands the resample straight back. Drag instead.

## Rotation is stated twice, and applying both turns the picture sideways

A HEIF can say how to turn itself in two places: as a container transform (`irot` and `imir`) and as an EXIF Orientation tag. Every Fujifilm HIF writes both, saying the same thing each way. Applying either is correct. Applying both is a photograph on its side, which is why one file can look right in one app and wrong in another on the same phone.

So no decoder is trusted about this. `sips` applies neither and passes the tag forward, while `djxl` bakes rotation in, and a program that believes either one plans a portrait as a landscape. ig-prep compares the decoded dimensions against the metadata's display dimensions and decides from the measurement. Across a 160-file corpus, 114 files state their rotation twice and **zero disagree**, which is the number `ig-prep --check` exists to report.

## Point a model at it

`ig-prep mcp` speaks MCP on stdin and stdout. Three tools: `ig_plan` reports what a conversion would do and writes nothing, `ig_convert` does it, and `ig_check_rotation` is the rotation report above.

```
{
  "mcpServers": {
    "ig-prep": {
      "command": "ig-prep",
      "args": ["mcp", "--root", "/Users/you/Pictures"]
    }
  }
}
```

Then you can ask for the conversion in a sentence, and the model reads back what happened per file.

**The photographs never move and never enter the conversation.** They are already on the machine the server runs on, so a result carries paths, sizes and dimensions instead of image data. That is a deliberate limit. A converted frame is a few hundred kilobytes, which is roughly a megabyte of base64, and forty of them would spend forty megabytes of a model's context on pixels it cannot look at.

`--root` is the boundary, and it is checked after symlinks are resolved: comparing the path string a caller wrote would let `root/link-to-elsewhere` through. One call converts at most 200 files and reports how many it left, because a result listing 200 conversions of a 900-file directory otherwise reads as a complete run.

Eight 40 megapixel HEIFs through one call take 2.41 seconds of wall clock on an M-series Mac, at 354% CPU across the cores.

## What the chroma choice costs

Instagram re-encodes to 4:2:0 regardless, so the question is not what survives on disk but what its encoder is handed. Full-resolution chroma means its subsampling step averages real detail rather than detail already halved once, and two successive halvings visibly smear saturated edges. The cost is bytes on an upload nobody sees. On one 1440x2160 frame:

A Fujifilm HIF halves chroma horizontally in its own landscape orientation, so held vertically the halved axis lands on the long edge of the portrait. Against a 1440x2160 output that is still 3.58x horizontally and 1.79x vertically of real chroma to average down, which is why 4:4:4 output is measured rather than invented. Somebody delivering at or near native size has a source that cannot fill it, and should say `--422` and mean it.

## Three constants are guesses, and they are marked as guesses

The target width of 1440, the 4:4:4 default, and the ratio band itself are all assumptions. 1440 is chosen over 1080 because the failure modes are asymmetric: if Instagram wants 1080 it downscales cleanly from 1440, and if it wants 1440 and gets 1080 it upscales and invents detail. Guessing high costs a resample. Guessing low costs the picture.

None of the three becomes a fact until a round trip measures it, which means uploading a variant grid, saving what Instagram serves back, and scoring each against its source. The upload is the one step a program cannot do for you, so that harness is still unbuilt and the constants live in one block of `geometry.rs` where calibrating means editing one place.

## The bug that only black-and-white photographs could find

Building the MCP mode turned up a failure that had been in the tool since it was written. Every grayscale JPEG died with `source image: Size of buffer is smaller than required`, an error naming neither the file nor the cause, raised two layers away from it inside the resizer.

The decoder's `jpeg_set_out_colorspace(RGB)` is a request rather than an instruction. For a single-channel source it declines and returns Luma anyway, silently, so the buffer came back at one byte per pixel while the code labelled it three. The repair reads the colourspace the decoder actually used instead of the one it was asked for, and checks the buffer length against the dimensions at the decode boundary, where the file name is still in hand. One file in a 160-file library failed before. None do now.

The general shape is worth more than the instance. **An API that takes a preference and reports no error when it declines will be read as an instruction**, and the mismatch surfaces wherever the wrong assumption is first load-bearing, which is rarely where it was made.

shipped · the understanding check is part of the page, not a gate

Source: https://aadhar.sh/garage/ig-prep
