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
2 changes: 1 addition & 1 deletion cal/src/templates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ a:hover { color: var(--link-hover); }
background: var(--paper);
/* inner inset */
box-shadow:
inset 1px 1px 0 oklch(94% 0.01 260),
inset 1px 1px 0 var(--surface-desktop),
inset -1px -1px 0 oklch(100% 0 0);
}
.xp-group > .legend {
Expand Down
1 change: 1 addition & 0 deletions design/tokens/colors.css
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
/* ── NEUTRALS — window/field background + text greys ─────────────────────*/
--window: oklch(100% 0 0); /* #FFFFFF content + input field bg */
--paper: oklch(99% 0.005 var(--hue-chrome)); /* faint-warm workspace bg */
--paper-cool: oklch(98% 0.004 calc(var(--hue-luna) - 13)); /* faint-cool panel bg: the cool twin of --paper */
--ink: oklch(21.78% 0 0); /* primary window text (near-black) */
--ink-soft: oklch(38.67% 0 0); /* body prose / secondary */
--ink-quiet: oklch(44.95% 0 0); /* #555555 notes, footers, field labels */
Expand Down
2 changes: 1 addition & 1 deletion pipelines/garage/specs/av2.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pipelines/garage/specs/dictionary.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pipelines/garage/specs/hidden-flags.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pipelines/garage/specs/htmx.json

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion pipelines/garage/specs/ig-prep.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"status": "shipped",
"added": "2026-08-24",
"bodyHtml": "<h1>Handing Instagram pixels it has no reason to resample</h1>\n<p class=\"garage-intro\">Instagram resizes and re-encodes whatever it is given. What it does not do is <a class=\"term\" data-t=\"image-scaling\" href=\"https://en.wikipedia.org/wiki/Image_scaling\" title=\"Computing a new grid of pixels for an image at a different size, estimating each new pixel from the old ones around it.\" rel=\"external\">resample</a> a frame that already arrives at the size it wants. <a href=\"https://github.com/oddharsh/ig-prep\">ig-prep</a> does the destructive work here, carefully, so the platform has as little left to do as possible. It also speaks <a href=\"https://modelcontextprotocol.io\">MCP</a>, so a model can run it against photographs on your own machine without any of them moving.</p>\n\n<h2>The geometric fact the default rests on</h2>\n<p>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.</p>\n<p>The default gives nothing up, because <b>cropping a portrait to 4:5 removes height only</b>. 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 <a class=\"term\" data-t=\"linear-light\" href=\"https://en.wikipedia.org/wiki/Gamma_correction\" title=\"Pixel values proportional to the actual amount of light. Ordinary image files store gamma-encoded values, so averaging those directly gets brightness wrong.\" rel=\"external\">linear light</a> with <a class=\"term\" data-t=\"lanczos-resampling\" href=\"https://en.wikipedia.org/wiki/Lanczos_resampling\" title=\"A resizing filter that weights nearby pixels with a windowed sinc curve. Lanczos3 reaches three pixels out on each side and keeps images sharp.\" rel=\"external\">Lanczos3</a>, stays here where it is done once and done carefully.</p>\n<p class=\"ig-warn\">One thing breaks it: pinch-zooming in the crop UI changes the scale and hands the resample straight back. Drag instead.</p>\n\n<h2>Rotation is stated twice, and applying both turns the picture sideways</h2>\n<p>A <a class=\"term\" data-t=\"heif\" href=\"https://en.wikipedia.org/wiki/High_Efficiency_Image_File_Format\" title=\"An image container format, used by iPhones and by Fujifilm's HIF files, that usually holds HEVC-compressed pictures plus their metadata.\" rel=\"external\">HEIF</a> can say how to turn itself in two places: as a container transform (<code>irot</code> and <code>imir</code>) and as an <a class=\"term\" data-t=\"exif\" href=\"https://en.wikipedia.org/wiki/Exif\" title=\"Metadata a camera writes into a photo: exposure settings, date, lens, and an Orientation tag saying which way up to show it.\" rel=\"external\">EXIF</a> 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.</p>\n<p>So no decoder is trusted about this. <code>sips</code> applies neither and passes the tag forward, while <code>djxl</code> 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 <b>zero disagree</b>, which is the number <code>ig-prep --check</code> exists to report.</p>\n\n<h2>Point a model at it</h2>\n<p><code>ig-prep mcp</code> speaks MCP on <a class=\"term\" data-t=\"standard-streams\" href=\"https://en.wikipedia.org/wiki/Standard_streams\" title=\"A program's standard input, the stream it reads from by default. A tool that talks over stdin and stdout can be driven by another program.\" rel=\"external\">stdin</a> and stdout. Three tools: <code>ig_plan</code> reports what a conversion would do and writes nothing, <code>ig_convert</code> does it, and <code>ig_check_rotation</code> is the rotation report above.</p>\n<pre class=\"ig-conf\"><code>{\n \"mcpServers\": {\n \"ig-prep\": {\n \"command\": \"ig-prep\",\n \"args\": [\"mcp\", \"--root\", \"/Users/you/Pictures\"]\n }\n }\n}</code></pre>\n<p>Then you can ask for the conversion in a sentence, and the model reads back what happened per file.</p>\n<p><b>The photographs never move and never enter the conversation.</b> 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 <a class=\"term\" data-t=\"base64\" href=\"https://en.wikipedia.org/wiki/Base64\" title=\"A way to write binary data as plain text using 64 safe characters. It makes the data about a third larger.\" rel=\"external\">base64</a>, and forty of them would spend forty megabytes of a model's <a class=\"term\" data-t=\"context-window\" href=\"https://en.wikipedia.org/wiki/Context_window\" title=\"The amount of text, counted in tokens, that a language model can take in at once, including its own reply.\" rel=\"external\">context</a> on pixels it cannot look at.</p>\n<p><code>--root</code> is the boundary, and it is checked after <a class=\"term\" data-t=\"symbolic-link\" href=\"https://en.wikipedia.org/wiki/Symbolic_link\" title=\"A file that points to another file or folder by path, so opening it opens the target.\" rel=\"external\">symlinks</a> are resolved: comparing the path string a caller wrote would let <code>root/link-to-elsewhere</code> 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.</p>\n<p>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.</p>\n\n<h2>What the chroma choice costs</h2>\n<p>Instagram re-encodes to <a class=\"term\" data-t=\"chroma-subsampling\" href=\"https://en.wikipedia.org/wiki/Chroma_subsampling\" title=\"Storing colour at lower resolution than brightness, since eyes notice brightness detail more. 4:4:4 keeps full colour and 4:2:0 keeps a quarter.\" rel=\"external\">4:2:0</a> 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:</p>\n<div class=\"ig-table\" id=\"ig-chroma\"></div>\n<p>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 <code>--422</code> and mean it.</p>\n\n<h2>Three constants are guesses, and they are marked as guesses</h2>\n<p>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.</p>\n<p>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 <code>geometry.rs</code> where calibrating means editing one place.</p>\n\n<h2>The bug that only black-and-white photographs could find</h2>\n<p>Building the MCP mode turned up a failure that had been in the tool since it was written. Every grayscale JPEG died with <code>source image: Size of buffer is smaller than required</code>, an error naming neither the file nor the cause, raised two layers away from it inside the resizer.</p>\n<p>The decoder's <code>jpeg_set_out_colorspace(RGB)</code> 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.</p>\n<p>The general shape is worth more than the instance. <b>An API that takes a preference and reports no error when it declines will be read as an instruction</b>, and the mismatch surfaces wherever the wrong assumption is first load-bearing, which is rarely where it was made.</p>",
"pageCss": ".ig-warn{border-left:3px solid #d98a00;background:#fffaf0;padding:6px 10px;margin:10px 0}\n.ig-conf{border:1px solid #99a8bd;background:var(--row-alt);box-shadow:inset 1px 1px 0 #fff;padding:8px 10px;overflow-x:auto;font-size:var(--text-micro);margin:8px 0}\n.ig-table{border:1px solid #99a8bd;background:var(--row-alt);box-shadow:inset 1px 1px 0 #fff;margin:8px 0;padding:2px 0}\n.ig-table .row{display:grid;grid-template-columns:5em 7em 1fr;gap:8px;align-items:baseline;padding:3px 10px;font-size:var(--text-xs);border-bottom:1px solid #e4eaf3}\n.ig-table .row:last-child{border-bottom:0}\n.ig-table .row.head{font-weight:700;background:var(--surface-desktop)}\n.ig-table .n{text-align:right;font-family:var(--font-mono)}",
"pageCss": ".ig-warn{border-left:3px solid #d98a00;background:#fffaf0;padding:6px 10px;margin:10px 0}\n.ig-conf{border:1px solid #99a8bd;background:var(--row-alt);box-shadow:inset 1px 1px 0 #fff;padding:8px 10px;overflow-x:auto;font-size:var(--text-micro);margin:8px 0}\n.ig-table{border:1px solid #99a8bd;background:var(--row-alt);box-shadow:inset 1px 1px 0 #fff;margin:8px 0;padding:2px 0}\n.ig-table .row{display:grid;grid-template-columns:5em 7em 1fr;gap:8px;align-items:baseline;padding:3px 10px;font-size:var(--text-xs);border-bottom:1px solid var(--surface-desktop)}\n.ig-table .row:last-child{border-bottom:0}\n.ig-table .row.head{font-weight:700;background:var(--surface-desktop)}\n.ig-table .n{text-align:right;font-family:var(--font-mono)}",
"pageJs": "(function(){\n // The bytes are the measurement, so they are written here once and rendered\n // rather than restated in prose where the two copies could drift.\n var rows = [\n [\"4:4:4\", 1885, \"chroma at luma resolution, what this ships by default\"],\n [\"4:2:2\", 1699, \"halved horizontally, what the camera shot\"],\n [\"4:2:0\", 1585, \"halved on both axes, what Instagram stores anyway\"]\n ];\n var el = document.getElementById(\"ig-chroma\");\n if (!el) return;\n var html = '<div class=\"row head\"><span>chroma</span><span class=\"n\">KB</span><span>what it is</span></div>';\n for (var i = 0; i < rows.length; i++) {\n html += '<div class=\"row\"><span>' + rows[i][0] + '</span><span class=\"n\">' +\n rows[i][1] + '</span><span>' + rows[i][2] + '</span></div>';\n }\n el.innerHTML = html;\n})();",
"editorial": {
"reader": "A photographer who has noticed their pictures look worse after posting, and who wants to know which part of that is recoverable before the upload rather than after it.",
Expand Down
Loading
Loading