gainmap-audit 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. gainmap_audit-0.1.0/.github/workflows/ci.yml +23 -0
  2. gainmap_audit-0.1.0/.github/workflows/publish.yml +65 -0
  3. gainmap_audit-0.1.0/.gitignore +13 -0
  4. gainmap_audit-0.1.0/CHANGELOG.md +22 -0
  5. gainmap_audit-0.1.0/LICENSE +21 -0
  6. gainmap_audit-0.1.0/PKG-INFO +235 -0
  7. gainmap_audit-0.1.0/PROGRESS.md +38 -0
  8. gainmap_audit-0.1.0/README.md +205 -0
  9. gainmap_audit-0.1.0/pyproject.toml +63 -0
  10. gainmap_audit-0.1.0/src/gainmap_audit/__init__.py +5 -0
  11. gainmap_audit-0.1.0/src/gainmap_audit/cli.py +223 -0
  12. gainmap_audit-0.1.0/src/gainmap_audit/detect.py +448 -0
  13. gainmap_audit-0.1.0/src/gainmap_audit/isobmff.py +377 -0
  14. gainmap_audit-0.1.0/src/gainmap_audit/jpeg.py +263 -0
  15. gainmap_audit-0.1.0/src/gainmap_audit/pairing.py +201 -0
  16. gainmap_audit-0.1.0/src/gainmap_audit/reader.py +74 -0
  17. gainmap_audit-0.1.0/src/gainmap_audit/report.py +141 -0
  18. gainmap_audit-0.1.0/src/gainmap_audit/xmp.py +130 -0
  19. gainmap_audit-0.1.0/tests/builders.py +131 -0
  20. gainmap_audit-0.1.0/tests/builders_isobmff.py +72 -0
  21. gainmap_audit-0.1.0/tests/corpus/SOURCES.md +19 -0
  22. gainmap_audit-0.1.0/tests/corpus/apple_gainmap_new.jpg +0 -0
  23. gainmap_audit-0.1.0/tests/corpus/apple_hdr_sample.heic +0 -0
  24. gainmap_audit-0.1.0/tests/corpus/colors_sdr_srgb.avif +0 -0
  25. gainmap_audit-0.1.0/tests/corpus/paris_exif_xmp_gainmap_bigendian.jpg +0 -0
  26. gainmap_audit-0.1.0/tests/corpus/sample_srgb.jpg +0 -0
  27. gainmap_audit-0.1.0/tests/corpus/seine_sdr_gainmap_notmapbrand.avif +0 -0
  28. gainmap_audit-0.1.0/tests/corpus/small_uhdr.jpg +0 -0
  29. gainmap_audit-0.1.0/tests/test_cli.py +176 -0
  30. gainmap_audit-0.1.0/tests/test_corpus.py +55 -0
  31. gainmap_audit-0.1.0/tests/test_detect.py +264 -0
  32. gainmap_audit-0.1.0/tests/test_download_integration.py +67 -0
  33. gainmap_audit-0.1.0/tests/test_isobmff.py +154 -0
  34. gainmap_audit-0.1.0/tests/test_jpeg.py +131 -0
  35. gainmap_audit-0.1.0/tests/test_pairing.py +188 -0
  36. gainmap_audit-0.1.0/tests/test_roundtrip.py +45 -0
  37. gainmap_audit-0.1.0/tests/test_xmp.py +136 -0
@@ -0,0 +1,23 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ strategy:
11
+ fail-fast: false
12
+ matrix:
13
+ os: [ubuntu-latest, macos-latest, windows-latest]
14
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
15
+ runs-on: ${{ matrix.os }}
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: ${{ matrix.python-version }}
21
+ - run: python -m pip install -e ".[dev]"
22
+ - run: ruff check .
23
+ - run: pytest
@@ -0,0 +1,65 @@
1
+ name: Publish
2
+
3
+ on:
4
+ push:
5
+ tags: ["v[0-9]+.[0-9]+.[0-9]+"]
6
+ workflow_dispatch:
7
+
8
+ permissions:
9
+ contents: read
10
+
11
+ jobs:
12
+ build:
13
+ name: build
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.12"
21
+
22
+ # The version lives in both pyproject.toml and __init__.py, so check the
23
+ # tag against both rather than letting them drift apart silently.
24
+ - name: Check the tag matches the package version
25
+ if: startsWith(github.ref, 'refs/tags/')
26
+ run: |
27
+ set -euo pipefail
28
+ tag="${GITHUB_REF_NAME#v}"
29
+ init=$(python -c "import re;print(re.search(r'__version__ = \"([^\"]+)\"', open('src/gainmap_audit/__init__.py').read()).group(1))")
30
+ proj=$(python -c "import re;print(re.search(r'^version = \"([^\"]+)\"', open('pyproject.toml').read(), re.M).group(1))")
31
+ test "${tag}" = "${init}" || { echo "tag ${tag} != __init__ ${init}"; exit 1; }
32
+ test "${tag}" = "${proj}" || { echo "tag ${tag} != pyproject ${proj}"; exit 1; }
33
+
34
+ - run: python -m pip install --disable-pip-version-check build twine
35
+ - run: python -m build
36
+ - run: python -m twine check dist/*
37
+
38
+ - uses: actions/upload-artifact@v4
39
+ with:
40
+ name: dist
41
+ path: dist/
42
+
43
+ publish:
44
+ name: publish to PyPI
45
+ needs: build
46
+ runs-on: ubuntu-latest
47
+ environment:
48
+ name: pypi
49
+ url: https://pypi.org/p/gainmap-audit
50
+ permissions:
51
+ id-token: write
52
+ steps:
53
+ - uses: actions/download-artifact@v4
54
+ with:
55
+ name: dist
56
+ path: dist/
57
+
58
+ # Uses the PYPI_API_TOKEN secret. To move to Trusted Publishing, register this workflow
59
+ # and the `pypi` environment on pypi.org, then drop the `password` line; the id-token
60
+ # permission above is already in place for it.
61
+ - name: Publish
62
+ uses: pypa/gh-action-pypi-publish@release/v1
63
+ with:
64
+ password: ${{ secrets.PYPI_API_TOKEN }}
65
+ skip-existing: true
@@ -0,0 +1,13 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .venv/
8
+ venv/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ .coverage
12
+ htmlcov/
13
+ tests/.cache/
@@ -0,0 +1,22 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here.
4
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and this project uses [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.1.0] - 2026-09-22
8
+
9
+ Initial release.
10
+
11
+ ### Added
12
+ - `gmaudit check` / `scan` / `diff` CLI subcommands with `--json`, `--csv`,
13
+ `--fail-on`, and `--verify-with-uhdrtool` options.
14
+ - Detection of four HDR gain map flavours: Adobe/Google Ultra HDR
15
+ (`ultrahdr`), ISO/IEC TS 21496-1 in JPEG (`iso-jpeg`) and in HEIF/AVIF
16
+ (`iso-heif`), and Apple's auxiliary gain map (`apple-aux`).
17
+ - `orphaned` state for files whose MPF/HEIF plumbing still references a
18
+ gain map that nothing points a viewer at.
19
+ - Filename-based pairing (`stem` or `relpath`) for auditing a source tree
20
+ against its exports.
21
+ - Dependency-free, stdlib-only implementation; read-only, never modifies
22
+ the files it inspects.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Chris Bosch
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,235 @@
1
+ Metadata-Version: 2.5
2
+ Name: gainmap-audit
3
+ Version: 0.1.0
4
+ Summary: Find photos whose HDR gain map was lost in an editing round trip
5
+ Project-URL: Homepage, https://github.com/Booyaka101/gainmap-audit
6
+ Project-URL: Repository, https://github.com/Booyaka101/gainmap-audit
7
+ Project-URL: Changelog, https://github.com/Booyaka101/gainmap-audit/blob/main/CHANGELOG.md
8
+ Project-URL: Issues, https://github.com/Booyaka101/gainmap-audit/issues
9
+ Author-email: Chris Bosch <cbosch101@gmail.com>
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: avif,gain-map,hdr,heic,jpeg,metadata,photography,ultrahdr
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Environment :: Console
15
+ Classifier: Intended Audience :: End Users/Desktop
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python :: 3
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Topic :: Multimedia :: Graphics
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.10
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7; extra == 'dev'
28
+ Requires-Dist: ruff>=0.6; extra == 'dev'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # gainmap-audit
32
+
33
+ Find photos whose HDR gain map was lost in an editing round trip.
34
+
35
+ An HDR gain map is the extra metadata that lets a JPEG or HEIC render bright
36
+ highlights on an HDR display while staying a normal SDR image everywhere
37
+ else (Apple's "HDR photo" format, Google's Ultra HDR, and the ISO 21496-1
38
+ standard all work this way). It is exactly the kind of thing an editor,
39
+ resizer, or cloud photo library silently drops: the photo still opens fine,
40
+ it just renders flat everywhere from then on. `gmaudit` finds those photos
41
+ before you notice months later that your library went dark.
42
+
43
+ **`gmaudit` never writes to your images.** It opens files read-only, parses
44
+ container metadata, and reports what it finds. It does not decode pixels,
45
+ does not repair anything, and does not touch the filesystem except to read.
46
+ If you want to actually rebuild a stripped gain map, that's a job for
47
+ [`uhdrtool`](https://github.com/google/libultrahdr); `gmaudit` can shell out
48
+ to it for cross-verification (`--verify-with-uhdrtool`) but never invokes it
49
+ to modify a file.
50
+
51
+ ## Install
52
+
53
+ ```
54
+ pip install gainmap-audit
55
+ ```
56
+
57
+ Requires Python 3.10+. No dependencies beyond the standard library.
58
+
59
+ ## The problem, in one real run
60
+
61
+ An iPhone photo with an Apple HDR gain map, exported through an editor that
62
+ doesn't understand gain maps, comes back as a plain JPEG with the same name.
63
+ Nothing in a file browser or thumbnail flags this:
64
+
65
+ ```
66
+ $ gmaudit diff photos/ export/
67
+ VERDICT PAIR STATE
68
+ STRIPPED IMG_0421.HEIC -> IMG_0421.jpg apple-aux -> none
69
+ 1 pair: 1 stripped
70
+ $ echo $?
71
+ 1
72
+ ```
73
+
74
+ `gmaudit` paired the files by name, classified each one, and flagged the
75
+ loss. The same photo can also come back `iso-heif -> none` (Apple began
76
+ shipping ISO 21496-1 gain maps in iOS 18) -- either way, once the source
77
+ carried a gain map and the export doesn't, `gmaudit` calls it `STRIPPED`.
78
+
79
+ ## Usage
80
+
81
+ ### Classify individual files
82
+
83
+ ```
84
+ $ gmaudit check IMG_0421.HEIC
85
+ STATE PATH SIZE RULES ERROR
86
+ APPLE-AUX IMG_0421.HEIC 238982 apple-aux
87
+ 1 file: 1 apple-aux
88
+ ```
89
+
90
+ Add `--json` for machine-readable output:
91
+
92
+ ```
93
+ $ gmaudit check IMG_0421.HEIC --json
94
+ {
95
+ "files": [
96
+ {
97
+ "path": "IMG_0421.HEIC",
98
+ "state": "apple-aux",
99
+ "container": "isobmff",
100
+ "size": 238982,
101
+ "rules_fired": ["apple-aux"],
102
+ "evidence": [
103
+ {"rule": "ftyp", "detail": "brands heic, mif1, MiHE, miaf, MiHB", "offset": 0},
104
+ {
105
+ "rule": "apple-aux",
106
+ "detail": "ipma links item 10 to auxC aux_type urn:com:apple:photo:2020:aux:hdrgainmap; iref auxl -> base item 7",
107
+ "offset": 36
108
+ }
109
+ ],
110
+ "gain_map": {"source": "auxc-item", "offset": null, "length": null, "mime": "", "item_id": 10},
111
+ "error": null
112
+ }
113
+ ]
114
+ }
115
+ ```
116
+
117
+ ### Scan a whole folder
118
+
119
+ ```
120
+ $ gmaudit scan tests/corpus
121
+ STATE PATH SIZE RULES ERROR
122
+ APPLE-AUX tests\corpus\apple_gainmap_new.jpg 50824 apple-aux
123
+ APPLE-AUX tests\corpus\apple_hdr_sample.heic 238982 apple-aux
124
+ NONE tests\corpus\colors_sdr_srgb.avif 18845 -
125
+ ULTRAHDR tests\corpus\paris_exif_xmp_gainmap_bigendian.jpg 47579 ultrahdr
126
+ NONE tests\corpus\sample_srgb.jpg 154458 -
127
+ ISO-HEIF tests\corpus\seine_sdr_gainmap_notmapbrand.avif 129769 iso-heif
128
+ ISO-JPEG tests\corpus\small_uhdr.jpg 39385 iso-jpeg
129
+ 7 files: 2 apple-aux, 2 none, 1 ultrahdr, 1 iso-heif, 1 iso-jpeg
130
+ ```
131
+
132
+ Add `-r` / `--recursive` to descend into subdirectories. Hidden directories,
133
+ `@eaDir`/`@__thumb`/`.thumbnails`/`$RECYCLE.BIN`, and sidecar files
134
+ (`.xmp`, `.aae`) are skipped automatically.
135
+
136
+ ### Diff a source tree against its exports
137
+
138
+ ```
139
+ $ gmaudit diff photos/ export/ -r
140
+ ```
141
+
142
+ Pairs files by filename stem, case-insensitively, so an extension change
143
+ across the round trip (`.HEIC` in, `.jpg` out) still matches. Pass
144
+ `--match relpath` to pair by path relative to each root instead, if your
145
+ export keeps the folder structure but changes every filename.
146
+
147
+ Each pair gets one of:
148
+
149
+ | Verdict | Meaning |
150
+ |---|---|
151
+ | `OK` | both sides carry a gain map |
152
+ | `STRIPPED` | source had one, export doesn't |
153
+ | `ORPHANED` | source had one, export has broken/unreachable gain map plumbing |
154
+ | `DEGRADED` | source never had one, export ended up with broken gain map plumbing anyway |
155
+ | `ADDED` | export gained a gain map the source didn't have |
156
+ | `SDR-BOTH` | neither side has one |
157
+ | `UNPAIRED-SOURCE` | no matching export found |
158
+ | `UNPAIRED-EXPORT` | no matching source found |
159
+ | `AMBIGUOUS` | more than one file on either side shares the same pairing key |
160
+ | `ERROR` | one side couldn't be read/parsed |
161
+
162
+ ### Global options
163
+
164
+ - `--json` -- emit JSON instead of the table (schema shown above; `diff` emits `{"pairs": [...]}`).
165
+ - `--csv PATH` -- also write results as CSV to `PATH`.
166
+ - `--fail-on LIST` -- comma-separated states/verdicts that make the process exit 1.
167
+ Default: `stripped,orphaned`.
168
+ - `--verify-with-uhdrtool [PATH]` -- run `uhdrtool detect -in FILE` on every JPEG
169
+ and print a warning to stderr wherever it disagrees with `gmaudit`'s verdict.
170
+ Looks up `uhdrtool` on `PATH` if no argument is given.
171
+
172
+ ### Exit codes
173
+
174
+ - `0` -- ran clean, nothing matched `--fail-on`.
175
+ - `1` -- at least one file/pair matched `--fail-on`, or one couldn't be read/parsed at all.
176
+ - `2` -- usage error: bad flags, missing directory, unreadable arguments.
177
+
178
+ ## What it detects
179
+
180
+ Four independent rules run against every file; each records its own
181
+ evidence, and the highest-precedence rule that fired wins the reported
182
+ state (`ultrahdr` > `iso-jpeg` > `iso-heif` > `apple-aux` > `orphaned`):
183
+
184
+ 1. **`ultrahdr`** -- `hdrgm:Version` in the primary image's XMP (Adobe/Google
185
+ Ultra HDR), cross-referenced against the `Container:Directory` and any
186
+ MPF secondary image.
187
+ 2. **`iso-jpeg`** -- an APP2 segment identified by the literal
188
+ `urn:iso:std:iso:ts:21496:-1` (ISO/IEC TS 21496-1).
189
+ 3. **`iso-heif`** -- a `tmap` derived item in the HEIF/AVIF `meta` box,
190
+ normally linked to its base image by an `iref` `dimg` reference.
191
+ 4. **`apple-aux`** -- an `auxC` box naming
192
+ `urn:com:apple:photo:2020:aux:hdrgainmap` (HEIC), or an MPF secondary
193
+ image whose own XMP carries `apdi:AuxiliaryImageType` or
194
+ `HDRGainMap:HDRGainMapVersion` (JPEG). If the container can't be parsed
195
+ at all, a byte-level scan for the same URN literal is used as a fallback
196
+ (evidence rule `urn-scan`), so a file with an unusual box layout still
197
+ gets flagged rather than silently reported clean.
198
+
199
+ A fifth state, **`orphaned`**, is not a gain map flavour but a warning: the
200
+ MPF index still lists a second image that looks like a gain map, but nothing
201
+ in the primary XMP points a viewer at it. That's what a half-completed strip
202
+ or a buggy re-encode looks like from the outside, and it's exactly the case
203
+ `--fail-on orphaned` (the default) is there to catch.
204
+
205
+ `gmaudit` never crashes on a bad file. Truncated, zero-byte, unreadable, or
206
+ non-image files are reported with `state: error` and a reason, not a
207
+ traceback.
208
+
209
+ ## Testing
210
+
211
+ ```
212
+ pip install -e ".[dev]"
213
+ pytest
214
+ ruff check .
215
+ ```
216
+
217
+ The test suite builds its own JPEG and ISOBMFF byte streams for unit tests
218
+ (`tests/builders.py`, `tests/builders_isobmff.py`), classifies a committed
219
+ corpus of real sample files (`tests/corpus/`, sourced and licensed per
220
+ `tests/corpus/SOURCES.md`), and strips XMP from a real Ultra HDR JPEG inside
221
+ `tests/test_roundtrip.py` to prove the `orphaned` detection actually fires on
222
+ real bytes, not just synthetic ones. `tests/test_download_integration.py`
223
+ pulls two more pinned real files over the network and skips cleanly if it
224
+ can't reach them.
225
+
226
+ ## Limitations
227
+
228
+ - Detects the four gain map flavours in current use. A future or
229
+ proprietary flavour with none of these markers reports `none`, correctly
230
+ meaning "no gain map found," not "verified absent by inspecting pixels."
231
+ - Pairing across a source and export tree is filename-based (stem or
232
+ relative path). If an export tool renames files unpredictably, pairing
233
+ won't find the match.
234
+ - `--verify-with-uhdrtool` only checks JPEGs, since that's what `uhdrtool`
235
+ understands.
@@ -0,0 +1,38 @@
1
+ # Build progress
2
+
3
+ Autonomous build log for `gainmap-audit`. Newest entry last.
4
+
5
+ ## Phase 1 — core parsers and detection
6
+ - `reader.py`: bounded, read-only file windowing (`FileWindow.read_at`/`find`), never opens a file twice.
7
+ - `jpeg.py`: marker-stream walker (SOI/APPn/SOS/EOI), APP1 XMP (standard + extended/GUID-reassembled), APP2 MPF (CIPA DC-007, 16-byte MP Entry, offsets relative to the MP Header).
8
+ - `isobmff.py`: box walker for ftyp/meta/iinf/infe/iref/iprp/ipco/auxC/pitm, HEIF/AVIF item graph traversal.
9
+ - `xmp.py`: namespace-aware textual RDF/XML lookup (no DOM parser), `Xmp.declares`/`get`/`has`/`prefixes_for`/`container_items`/`gain_map_item`.
10
+ - `detect.py`: four precedence-resolved rules (`ultrahdr` > `iso-jpeg` > `iso-heif` > `apple-aux`), plus `orphaned` and `error` states.
11
+ - Status: done.
12
+
13
+ ## Phase 2 — pairing, CLI, reporting
14
+ - `pairing.py`: filename pairing (`stem`/`relpath`), verdict matrix (OK/STRIPPED/ORPHANED/DEGRADED/ADDED/SDR-BOTH/UNPAIRED-*/AMBIGUOUS/ERROR), directory walk skipping sidecars/hidden/junk dirs.
15
+ - `cli.py`: `check`/`scan`/`diff` subcommands, `--json`/`--csv`/`--fail-on`/`--verify-with-uhdrtool`, exit 0/1/2.
16
+ - `report.py`: human table and CSV writers.
17
+ - Status: done.
18
+
19
+ ## Phase 3 — tests
20
+ - Unit tests build real byte streams for every format (`tests/builders.py`, `tests/builders_isobmff.py`) — no mocks.
21
+ - `tests/corpus/` — 7 real sample files (licensed, sourced in `SOURCES.md`), classified against a documented expected-state table.
22
+ - `tests/test_roundtrip.py` — strips real XMP from a real Ultra HDR JPEG, proves the resulting `orphaned` classification on real bytes.
23
+ - `tests/test_download_integration.py` — two more pinned real files fetched over the network with sha256 verification, skips cleanly offline.
24
+ - `tests/test_cli.py` — exit codes, JSON schema, the brief's HEIC->JPG worked example end to end through the CLI.
25
+ - Result: 101 tests, all passing; `ruff check .` clean.
26
+ - Status: done.
27
+
28
+ ## Phase 4 — packaging and docs
29
+ - `pyproject.toml` (hatchling, stdlib-only, console_script `gmaudit`), version 0.1.0.
30
+ - `README.md` with real captured CLI output (not hand-typed), install/usage/detection-rules/limitations sections, explicit "never modifies files" statement.
31
+ - `CHANGELOG.md` starting at 0.1.0, `LICENSE` (MIT), `.gitignore`, `.github/workflows/ci.yml` (ubuntu/macos/windows x py3.10-3.13).
32
+ - Verified: `python -m build --wheel` succeeds, `pip install dist/*.whl && gmaudit --version` works in a clean venv (`gmaudit 0.1.0`).
33
+ - Status: done.
34
+
35
+ ## Phase 5 — final review
36
+ - Clone-detection check (difflib on AST-extracted function line ranges): `jpeg.py` vs `isobmff.py` whole-module similarity 12.2%; `_rule_iso_jpeg` vs `_rule_iso_heif` 0.0%; `_rule_apple_jpeg` vs `_rule_apple_heif` 4.3%. All well under the ~60% extract-shared-mechanism threshold — the four detection rules are genuinely different mechanisms (raw byte/XMP matching vs. parsed ISOBMFF item-graph traversal), not clones.
37
+ - Testing/build/lint bugs found and fixed during review were all in test fixtures (wrong MPF entry byte width, wrong MPF data_offset base, wrong MP type constant, one stale corpus doc note) — zero defects found in shipped production code.
38
+ - Status: done.
@@ -0,0 +1,205 @@
1
+ # gainmap-audit
2
+
3
+ Find photos whose HDR gain map was lost in an editing round trip.
4
+
5
+ An HDR gain map is the extra metadata that lets a JPEG or HEIC render bright
6
+ highlights on an HDR display while staying a normal SDR image everywhere
7
+ else (Apple's "HDR photo" format, Google's Ultra HDR, and the ISO 21496-1
8
+ standard all work this way). It is exactly the kind of thing an editor,
9
+ resizer, or cloud photo library silently drops: the photo still opens fine,
10
+ it just renders flat everywhere from then on. `gmaudit` finds those photos
11
+ before you notice months later that your library went dark.
12
+
13
+ **`gmaudit` never writes to your images.** It opens files read-only, parses
14
+ container metadata, and reports what it finds. It does not decode pixels,
15
+ does not repair anything, and does not touch the filesystem except to read.
16
+ If you want to actually rebuild a stripped gain map, that's a job for
17
+ [`uhdrtool`](https://github.com/google/libultrahdr); `gmaudit` can shell out
18
+ to it for cross-verification (`--verify-with-uhdrtool`) but never invokes it
19
+ to modify a file.
20
+
21
+ ## Install
22
+
23
+ ```
24
+ pip install gainmap-audit
25
+ ```
26
+
27
+ Requires Python 3.10+. No dependencies beyond the standard library.
28
+
29
+ ## The problem, in one real run
30
+
31
+ An iPhone photo with an Apple HDR gain map, exported through an editor that
32
+ doesn't understand gain maps, comes back as a plain JPEG with the same name.
33
+ Nothing in a file browser or thumbnail flags this:
34
+
35
+ ```
36
+ $ gmaudit diff photos/ export/
37
+ VERDICT PAIR STATE
38
+ STRIPPED IMG_0421.HEIC -> IMG_0421.jpg apple-aux -> none
39
+ 1 pair: 1 stripped
40
+ $ echo $?
41
+ 1
42
+ ```
43
+
44
+ `gmaudit` paired the files by name, classified each one, and flagged the
45
+ loss. The same photo can also come back `iso-heif -> none` (Apple began
46
+ shipping ISO 21496-1 gain maps in iOS 18) -- either way, once the source
47
+ carried a gain map and the export doesn't, `gmaudit` calls it `STRIPPED`.
48
+
49
+ ## Usage
50
+
51
+ ### Classify individual files
52
+
53
+ ```
54
+ $ gmaudit check IMG_0421.HEIC
55
+ STATE PATH SIZE RULES ERROR
56
+ APPLE-AUX IMG_0421.HEIC 238982 apple-aux
57
+ 1 file: 1 apple-aux
58
+ ```
59
+
60
+ Add `--json` for machine-readable output:
61
+
62
+ ```
63
+ $ gmaudit check IMG_0421.HEIC --json
64
+ {
65
+ "files": [
66
+ {
67
+ "path": "IMG_0421.HEIC",
68
+ "state": "apple-aux",
69
+ "container": "isobmff",
70
+ "size": 238982,
71
+ "rules_fired": ["apple-aux"],
72
+ "evidence": [
73
+ {"rule": "ftyp", "detail": "brands heic, mif1, MiHE, miaf, MiHB", "offset": 0},
74
+ {
75
+ "rule": "apple-aux",
76
+ "detail": "ipma links item 10 to auxC aux_type urn:com:apple:photo:2020:aux:hdrgainmap; iref auxl -> base item 7",
77
+ "offset": 36
78
+ }
79
+ ],
80
+ "gain_map": {"source": "auxc-item", "offset": null, "length": null, "mime": "", "item_id": 10},
81
+ "error": null
82
+ }
83
+ ]
84
+ }
85
+ ```
86
+
87
+ ### Scan a whole folder
88
+
89
+ ```
90
+ $ gmaudit scan tests/corpus
91
+ STATE PATH SIZE RULES ERROR
92
+ APPLE-AUX tests\corpus\apple_gainmap_new.jpg 50824 apple-aux
93
+ APPLE-AUX tests\corpus\apple_hdr_sample.heic 238982 apple-aux
94
+ NONE tests\corpus\colors_sdr_srgb.avif 18845 -
95
+ ULTRAHDR tests\corpus\paris_exif_xmp_gainmap_bigendian.jpg 47579 ultrahdr
96
+ NONE tests\corpus\sample_srgb.jpg 154458 -
97
+ ISO-HEIF tests\corpus\seine_sdr_gainmap_notmapbrand.avif 129769 iso-heif
98
+ ISO-JPEG tests\corpus\small_uhdr.jpg 39385 iso-jpeg
99
+ 7 files: 2 apple-aux, 2 none, 1 ultrahdr, 1 iso-heif, 1 iso-jpeg
100
+ ```
101
+
102
+ Add `-r` / `--recursive` to descend into subdirectories. Hidden directories,
103
+ `@eaDir`/`@__thumb`/`.thumbnails`/`$RECYCLE.BIN`, and sidecar files
104
+ (`.xmp`, `.aae`) are skipped automatically.
105
+
106
+ ### Diff a source tree against its exports
107
+
108
+ ```
109
+ $ gmaudit diff photos/ export/ -r
110
+ ```
111
+
112
+ Pairs files by filename stem, case-insensitively, so an extension change
113
+ across the round trip (`.HEIC` in, `.jpg` out) still matches. Pass
114
+ `--match relpath` to pair by path relative to each root instead, if your
115
+ export keeps the folder structure but changes every filename.
116
+
117
+ Each pair gets one of:
118
+
119
+ | Verdict | Meaning |
120
+ |---|---|
121
+ | `OK` | both sides carry a gain map |
122
+ | `STRIPPED` | source had one, export doesn't |
123
+ | `ORPHANED` | source had one, export has broken/unreachable gain map plumbing |
124
+ | `DEGRADED` | source never had one, export ended up with broken gain map plumbing anyway |
125
+ | `ADDED` | export gained a gain map the source didn't have |
126
+ | `SDR-BOTH` | neither side has one |
127
+ | `UNPAIRED-SOURCE` | no matching export found |
128
+ | `UNPAIRED-EXPORT` | no matching source found |
129
+ | `AMBIGUOUS` | more than one file on either side shares the same pairing key |
130
+ | `ERROR` | one side couldn't be read/parsed |
131
+
132
+ ### Global options
133
+
134
+ - `--json` -- emit JSON instead of the table (schema shown above; `diff` emits `{"pairs": [...]}`).
135
+ - `--csv PATH` -- also write results as CSV to `PATH`.
136
+ - `--fail-on LIST` -- comma-separated states/verdicts that make the process exit 1.
137
+ Default: `stripped,orphaned`.
138
+ - `--verify-with-uhdrtool [PATH]` -- run `uhdrtool detect -in FILE` on every JPEG
139
+ and print a warning to stderr wherever it disagrees with `gmaudit`'s verdict.
140
+ Looks up `uhdrtool` on `PATH` if no argument is given.
141
+
142
+ ### Exit codes
143
+
144
+ - `0` -- ran clean, nothing matched `--fail-on`.
145
+ - `1` -- at least one file/pair matched `--fail-on`, or one couldn't be read/parsed at all.
146
+ - `2` -- usage error: bad flags, missing directory, unreadable arguments.
147
+
148
+ ## What it detects
149
+
150
+ Four independent rules run against every file; each records its own
151
+ evidence, and the highest-precedence rule that fired wins the reported
152
+ state (`ultrahdr` > `iso-jpeg` > `iso-heif` > `apple-aux` > `orphaned`):
153
+
154
+ 1. **`ultrahdr`** -- `hdrgm:Version` in the primary image's XMP (Adobe/Google
155
+ Ultra HDR), cross-referenced against the `Container:Directory` and any
156
+ MPF secondary image.
157
+ 2. **`iso-jpeg`** -- an APP2 segment identified by the literal
158
+ `urn:iso:std:iso:ts:21496:-1` (ISO/IEC TS 21496-1).
159
+ 3. **`iso-heif`** -- a `tmap` derived item in the HEIF/AVIF `meta` box,
160
+ normally linked to its base image by an `iref` `dimg` reference.
161
+ 4. **`apple-aux`** -- an `auxC` box naming
162
+ `urn:com:apple:photo:2020:aux:hdrgainmap` (HEIC), or an MPF secondary
163
+ image whose own XMP carries `apdi:AuxiliaryImageType` or
164
+ `HDRGainMap:HDRGainMapVersion` (JPEG). If the container can't be parsed
165
+ at all, a byte-level scan for the same URN literal is used as a fallback
166
+ (evidence rule `urn-scan`), so a file with an unusual box layout still
167
+ gets flagged rather than silently reported clean.
168
+
169
+ A fifth state, **`orphaned`**, is not a gain map flavour but a warning: the
170
+ MPF index still lists a second image that looks like a gain map, but nothing
171
+ in the primary XMP points a viewer at it. That's what a half-completed strip
172
+ or a buggy re-encode looks like from the outside, and it's exactly the case
173
+ `--fail-on orphaned` (the default) is there to catch.
174
+
175
+ `gmaudit` never crashes on a bad file. Truncated, zero-byte, unreadable, or
176
+ non-image files are reported with `state: error` and a reason, not a
177
+ traceback.
178
+
179
+ ## Testing
180
+
181
+ ```
182
+ pip install -e ".[dev]"
183
+ pytest
184
+ ruff check .
185
+ ```
186
+
187
+ The test suite builds its own JPEG and ISOBMFF byte streams for unit tests
188
+ (`tests/builders.py`, `tests/builders_isobmff.py`), classifies a committed
189
+ corpus of real sample files (`tests/corpus/`, sourced and licensed per
190
+ `tests/corpus/SOURCES.md`), and strips XMP from a real Ultra HDR JPEG inside
191
+ `tests/test_roundtrip.py` to prove the `orphaned` detection actually fires on
192
+ real bytes, not just synthetic ones. `tests/test_download_integration.py`
193
+ pulls two more pinned real files over the network and skips cleanly if it
194
+ can't reach them.
195
+
196
+ ## Limitations
197
+
198
+ - Detects the four gain map flavours in current use. A future or
199
+ proprietary flavour with none of these markers reports `none`, correctly
200
+ meaning "no gain map found," not "verified absent by inspecting pixels."
201
+ - Pairing across a source and export tree is filename-based (stem or
202
+ relative path). If an export tool renames files unpredictably, pairing
203
+ won't find the match.
204
+ - `--verify-with-uhdrtool` only checks JPEGs, since that's what `uhdrtool`
205
+ understands.