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.
- gainmap_audit-0.1.0/.github/workflows/ci.yml +23 -0
- gainmap_audit-0.1.0/.github/workflows/publish.yml +65 -0
- gainmap_audit-0.1.0/.gitignore +13 -0
- gainmap_audit-0.1.0/CHANGELOG.md +22 -0
- gainmap_audit-0.1.0/LICENSE +21 -0
- gainmap_audit-0.1.0/PKG-INFO +235 -0
- gainmap_audit-0.1.0/PROGRESS.md +38 -0
- gainmap_audit-0.1.0/README.md +205 -0
- gainmap_audit-0.1.0/pyproject.toml +63 -0
- gainmap_audit-0.1.0/src/gainmap_audit/__init__.py +5 -0
- gainmap_audit-0.1.0/src/gainmap_audit/cli.py +223 -0
- gainmap_audit-0.1.0/src/gainmap_audit/detect.py +448 -0
- gainmap_audit-0.1.0/src/gainmap_audit/isobmff.py +377 -0
- gainmap_audit-0.1.0/src/gainmap_audit/jpeg.py +263 -0
- gainmap_audit-0.1.0/src/gainmap_audit/pairing.py +201 -0
- gainmap_audit-0.1.0/src/gainmap_audit/reader.py +74 -0
- gainmap_audit-0.1.0/src/gainmap_audit/report.py +141 -0
- gainmap_audit-0.1.0/src/gainmap_audit/xmp.py +130 -0
- gainmap_audit-0.1.0/tests/builders.py +131 -0
- gainmap_audit-0.1.0/tests/builders_isobmff.py +72 -0
- gainmap_audit-0.1.0/tests/corpus/SOURCES.md +19 -0
- gainmap_audit-0.1.0/tests/corpus/apple_gainmap_new.jpg +0 -0
- gainmap_audit-0.1.0/tests/corpus/apple_hdr_sample.heic +0 -0
- gainmap_audit-0.1.0/tests/corpus/colors_sdr_srgb.avif +0 -0
- gainmap_audit-0.1.0/tests/corpus/paris_exif_xmp_gainmap_bigendian.jpg +0 -0
- gainmap_audit-0.1.0/tests/corpus/sample_srgb.jpg +0 -0
- gainmap_audit-0.1.0/tests/corpus/seine_sdr_gainmap_notmapbrand.avif +0 -0
- gainmap_audit-0.1.0/tests/corpus/small_uhdr.jpg +0 -0
- gainmap_audit-0.1.0/tests/test_cli.py +176 -0
- gainmap_audit-0.1.0/tests/test_corpus.py +55 -0
- gainmap_audit-0.1.0/tests/test_detect.py +264 -0
- gainmap_audit-0.1.0/tests/test_download_integration.py +67 -0
- gainmap_audit-0.1.0/tests/test_isobmff.py +154 -0
- gainmap_audit-0.1.0/tests/test_jpeg.py +131 -0
- gainmap_audit-0.1.0/tests/test_pairing.py +188 -0
- gainmap_audit-0.1.0/tests/test_roundtrip.py +45 -0
- 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,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.
|