gainmap-audit 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,5 @@
1
+ """Read-only auditing of HDR gain maps in photo files."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ __all__ = ["__version__"]
gainmap_audit/cli.py ADDED
@@ -0,0 +1,223 @@
1
+ """``gmaudit``: read-only detection, classification and folder diffing for HDR gain maps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from . import __version__, detect, report
12
+ from .detect import FileReport
13
+ from .pairing import ERROR as PAIR_ERROR
14
+ from .pairing import build_pairs, diff_pair, iter_image_files
15
+
16
+ EXIT_OK = 0
17
+ EXIT_FINDINGS = 1
18
+ EXIT_USAGE = 2
19
+
20
+ DEFAULT_FAIL_ON = ("stripped", "orphaned")
21
+ _ALL_VERDICTS = frozenset(
22
+ {
23
+ "stripped",
24
+ "orphaned",
25
+ "degraded",
26
+ "added",
27
+ "sdr-both",
28
+ "unpaired-source",
29
+ "unpaired-export",
30
+ "ambiguous",
31
+ "error",
32
+ }
33
+ )
34
+
35
+
36
+ def main(argv: list[str] | None = None) -> int:
37
+ parser = build_parser()
38
+ args = parser.parse_args(argv)
39
+ if args.command is None:
40
+ parser.print_help(sys.stderr)
41
+ return EXIT_USAGE
42
+
43
+ try:
44
+ fail_on = _parse_fail_on(args.fail_on)
45
+ except ValueError as exc:
46
+ parser.error(str(exc))
47
+ return EXIT_USAGE # unreachable, parser.error exits
48
+
49
+ try:
50
+ if args.command == "check":
51
+ return _run_check(args, fail_on)
52
+ if args.command == "scan":
53
+ return _run_scan(args, fail_on)
54
+ if args.command == "diff":
55
+ return _run_diff(args, fail_on)
56
+ except UsageError as exc:
57
+ print(f"gmaudit: error: {exc}", file=sys.stderr)
58
+ return EXIT_USAGE
59
+ parser.error(f"unknown command: {args.command}")
60
+ return EXIT_USAGE
61
+
62
+
63
+ class UsageError(Exception):
64
+ """A user-facing input problem: bad path, bad flag combination."""
65
+
66
+
67
+ def build_parser() -> argparse.ArgumentParser:
68
+ parser = argparse.ArgumentParser(
69
+ prog="gmaudit",
70
+ description="Find photos whose HDR gain map was lost in an editing round trip.",
71
+ )
72
+ parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
73
+ _add_global_options(parser)
74
+
75
+ subparsers = parser.add_subparsers(dest="command")
76
+
77
+ check = subparsers.add_parser("check", help="classify individual files")
78
+ check.add_argument("files", nargs="+", help="image files to classify")
79
+ _add_global_options(check)
80
+
81
+ scan = subparsers.add_parser("scan", help="classify every image under a directory")
82
+ scan.add_argument("directory", help="directory to scan")
83
+ scan.add_argument("-r", "--recursive", action="store_true", help="recurse into subdirectories")
84
+ _add_global_options(scan)
85
+
86
+ diff = subparsers.add_parser("diff", help="compare a source tree against its exports")
87
+ diff.add_argument("source_dir", help="directory of originals")
88
+ diff.add_argument("export_dir", help="directory of exported/edited copies")
89
+ diff.add_argument("-r", "--recursive", action="store_true", help="recurse into subdirectories")
90
+ diff.add_argument(
91
+ "--match",
92
+ choices=("stem", "relpath"),
93
+ default="stem",
94
+ help="pairing key: filename stem (default) or path relative to each root",
95
+ )
96
+ _add_global_options(diff)
97
+
98
+ return parser
99
+
100
+
101
+ def _add_global_options(parser: argparse.ArgumentParser) -> None:
102
+ parser.add_argument("--json", action="store_true", help="emit JSON instead of a table")
103
+ parser.add_argument("--csv", metavar="PATH", help="also write results as CSV to PATH")
104
+ parser.add_argument(
105
+ "--fail-on",
106
+ default=",".join(DEFAULT_FAIL_ON),
107
+ help="comma-separated states/verdicts that cause exit 1 "
108
+ f"(default: {','.join(DEFAULT_FAIL_ON)})",
109
+ )
110
+ parser.add_argument(
111
+ "--verify-with-uhdrtool",
112
+ nargs="?",
113
+ const="uhdrtool",
114
+ metavar="PATH",
115
+ help="cross-check JPEG verdicts against uhdrtool (default: look up 'uhdrtool' on PATH)",
116
+ )
117
+
118
+
119
+ def _parse_fail_on(raw: str) -> frozenset[str]:
120
+ values = frozenset(v.strip() for v in raw.split(",") if v.strip())
121
+ unknown = values - _ALL_VERDICTS - set(detect.STATES)
122
+ if unknown:
123
+ raise ValueError(f"unknown --fail-on value(s): {', '.join(sorted(unknown))}")
124
+ return values
125
+
126
+
127
+ def _run_check(args: argparse.Namespace, fail_on: frozenset[str]) -> int:
128
+ reports = [detect.classify(f) for f in args.files]
129
+ _attach_uhdrtool(reports, args.verify_with_uhdrtool)
130
+ _emit(reports, args, report.write_check_table, report.check_json, report.check_csv_rows)
131
+ return _exit_code(any(r.state in fail_on or r.state == detect.ERROR for r in reports))
132
+
133
+
134
+ def _run_scan(args: argparse.Namespace, fail_on: frozenset[str]) -> int:
135
+ directory = _require_dir(args.directory)
136
+ files = sorted(iter_image_files(directory, args.recursive, detect.IMAGE_EXTENSIONS))
137
+ if not files:
138
+ print(f"gmaudit: no image files found under {directory}", file=sys.stderr)
139
+ reports = [detect.classify(f) for f in files]
140
+ _attach_uhdrtool(reports, args.verify_with_uhdrtool)
141
+ _emit(reports, args, report.write_check_table, report.check_json, report.check_csv_rows)
142
+ return _exit_code(any(r.state in fail_on or r.state == detect.ERROR for r in reports))
143
+
144
+
145
+ def _run_diff(args: argparse.Namespace, fail_on: frozenset[str]) -> int:
146
+ source_root = _require_dir(args.source_dir)
147
+ export_root = _require_dir(args.export_dir)
148
+
149
+ sources = list(iter_image_files(source_root, args.recursive, detect.IMAGE_EXTENSIONS))
150
+ exports = list(iter_image_files(export_root, args.recursive, detect.IMAGE_EXTENSIONS))
151
+ pairs = build_pairs(sources, exports, source_root, export_root, args.match)
152
+
153
+ all_paths = {p for pair in pairs for p in (*pair.sources, *pair.exports)}
154
+ reports = {path: detect.classify(path) for path in all_paths}
155
+ _attach_uhdrtool(list(reports.values()), args.verify_with_uhdrtool)
156
+
157
+ diffs = [d for pair in pairs for d in diff_pair(pair, reports)]
158
+ _emit(diffs, args, report.write_diff_table, report.diff_json, report.diff_csv_rows)
159
+ return _exit_code(any(d.verdict in fail_on or d.verdict == PAIR_ERROR for d in diffs))
160
+
161
+
162
+ def _require_dir(raw: str) -> Path:
163
+ path = Path(raw)
164
+ if not path.is_dir():
165
+ raise UsageError(f"not a directory: {raw}")
166
+ return path
167
+
168
+
169
+ def _exit_code(found_failure: bool) -> int:
170
+ return EXIT_FINDINGS if found_failure else EXIT_OK
171
+
172
+
173
+ def _emit(items, args, table_writer, json_builder, csv_rows_builder) -> None:
174
+ if args.json:
175
+ report.write_json(json_builder(items), sys.stdout)
176
+ else:
177
+ table_writer(items, sys.stdout)
178
+ if args.csv:
179
+ report.write_csv(csv_rows_builder(items), args.csv)
180
+
181
+
182
+ def _attach_uhdrtool(reports: list[FileReport], tool: str | None) -> None:
183
+ """Run ``uhdrtool detect`` on JPEGs and note where it disagrees with us."""
184
+ if tool is None:
185
+ return
186
+ binary = shutil.which(tool) or (tool if Path(tool).is_file() else None)
187
+ if binary is None:
188
+ print(
189
+ f"gmaudit: warning: uhdrtool not found at {tool!r}, skipping verification",
190
+ file=sys.stderr,
191
+ )
192
+ return
193
+ for r in reports:
194
+ if r.container != "jpeg" or r.state == detect.ERROR:
195
+ continue
196
+ r.uhdrtool = _run_uhdrtool(binary, r)
197
+
198
+
199
+ def _run_uhdrtool(binary: str, r: FileReport) -> str:
200
+ try:
201
+ proc = subprocess.run(
202
+ [binary, "detect", "-in", str(r.path)],
203
+ capture_output=True,
204
+ text=True,
205
+ timeout=30,
206
+ )
207
+ except (OSError, subprocess.TimeoutExpired) as exc:
208
+ return f"error: could not run uhdrtool ({exc})"
209
+
210
+ verdict = proc.stdout.strip() or proc.stderr.strip() or f"exit {proc.returncode}"
211
+ theirs_has_map = verdict == "ultrahdr"
212
+ ours_has_map = r.has_gain_map
213
+ if proc.returncode == 0 and theirs_has_map != ours_has_map:
214
+ print(
215
+ f"!! disagreement: {r.path} -- gmaudit says {r.state} "
216
+ f"({'has' if ours_has_map else 'no'} gain map), uhdrtool says {verdict!r}",
217
+ file=sys.stderr,
218
+ )
219
+ return verdict
220
+
221
+
222
+ if __name__ == "__main__":
223
+ sys.exit(main())
@@ -0,0 +1,448 @@
1
+ """Classify a single file's gain map state.
2
+
3
+ Four detection rules run against every file and each records its own evidence;
4
+ the reported state is the highest-precedence rule that fired. Precedence is
5
+ ordered by how likely an arbitrary viewer is to render the HDR rendition, so
6
+ an Adobe/Google ``hdrgm`` block outranks an Apple-only auxiliary image even
7
+ when a file carries both.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ from dataclasses import dataclass, field
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from . import isobmff, jpeg, xmp
18
+ from .reader import FileError, FileWindow
19
+
20
+ NONE = "none"
21
+ ULTRAHDR = "ultrahdr"
22
+ ISO_JPEG = "iso-jpeg"
23
+ ISO_HEIF = "iso-heif"
24
+ APPLE_AUX = "apple-aux"
25
+ ORPHANED = "orphaned"
26
+ ERROR = "error"
27
+
28
+ STATES = (NONE, ULTRAHDR, ISO_JPEG, ISO_HEIF, APPLE_AUX, ORPHANED, ERROR)
29
+ GAIN_MAP_STATES = frozenset({ULTRAHDR, ISO_JPEG, ISO_HEIF, APPLE_AUX})
30
+
31
+ # Highest precedence first.
32
+ _PRECEDENCE = (ULTRAHDR, ISO_JPEG, ISO_HEIF, APPLE_AUX, ORPHANED)
33
+
34
+ # How widely the HDR rendition of each flavour is actually honoured.
35
+ INTEROP_RANK = {ULTRAHDR: 3, ISO_JPEG: 3, ISO_HEIF: 3, APPLE_AUX: 2, ORPHANED: 0, NONE: 0, ERROR: 0}
36
+
37
+ ISO_NAMESPACE = b"urn:iso:std:iso:ts:21496:-1\x00"
38
+ ISO_NAMESPACE_PREFIX = b"urn:iso:std:iso:ts:21496"
39
+
40
+ JPEG_EXTENSIONS = frozenset({".jpg", ".jpeg", ".jpe", ".jfif"})
41
+ ISOBMFF_EXTENSIONS = frozenset({".heic", ".heif", ".hif", ".avif"})
42
+ IMAGE_EXTENSIONS = JPEG_EXTENSIONS | ISOBMFF_EXTENSIONS
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class Evidence:
47
+ rule: str
48
+ detail: str
49
+ offset: int | None = None
50
+
51
+ def as_dict(self) -> dict[str, Any]:
52
+ return {"rule": self.rule, "detail": self.detail, "offset": self.offset}
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class GainMap:
57
+ """Where the gain map payload lives, when the container says."""
58
+
59
+ source: str
60
+ offset: int | None = None
61
+ length: int | None = None
62
+ mime: str = ""
63
+ item_id: int | None = None
64
+
65
+ def as_dict(self) -> dict[str, Any]:
66
+ return {
67
+ "source": self.source,
68
+ "offset": self.offset,
69
+ "length": self.length,
70
+ "mime": self.mime,
71
+ "item_id": self.item_id,
72
+ }
73
+
74
+
75
+ @dataclass
76
+ class FileReport:
77
+ path: Path
78
+ state: str
79
+ container: str
80
+ size: int
81
+ evidence: tuple[Evidence, ...] = ()
82
+ gain_map: GainMap | None = None
83
+ error: str | None = None
84
+ rules_fired: tuple[str, ...] = ()
85
+ uhdrtool: str | None = field(default=None, compare=False)
86
+
87
+ @property
88
+ def has_gain_map(self) -> bool:
89
+ return self.state in GAIN_MAP_STATES
90
+
91
+ @property
92
+ def rank(self) -> int:
93
+ return INTEROP_RANK.get(self.state, 0)
94
+
95
+ def as_dict(self) -> dict[str, Any]:
96
+ data: dict[str, Any] = {
97
+ "path": self.path.as_posix(),
98
+ "state": self.state,
99
+ "container": self.container,
100
+ "size": self.size,
101
+ "rules_fired": list(self.rules_fired),
102
+ "evidence": [e.as_dict() for e in self.evidence],
103
+ "gain_map": self.gain_map.as_dict() if self.gain_map else None,
104
+ "error": self.error,
105
+ }
106
+ if self.uhdrtool is not None:
107
+ data["uhdrtool"] = self.uhdrtool
108
+ return data
109
+
110
+
111
+ class _Findings:
112
+ """Collects per-rule evidence, then resolves the reported state."""
113
+
114
+ def __init__(self) -> None:
115
+ self.evidence: list[Evidence] = []
116
+ self.states: dict[str, GainMap | None] = {}
117
+
118
+ def note(self, rule: str, detail: str, offset: int | None = None) -> None:
119
+ self.evidence.append(Evidence(rule, detail, offset))
120
+
121
+ def fire(
122
+ self, state: str, detail: str, offset: int | None = None, gain_map: GainMap | None = None
123
+ ) -> None:
124
+ self.evidence.append(Evidence(state, detail, offset))
125
+ if state not in self.states or gain_map is not None:
126
+ self.states[state] = gain_map
127
+
128
+ def resolve(
129
+ self, container: str
130
+ ) -> tuple[str, tuple[Evidence, ...], GainMap | None, tuple[str, ...]]:
131
+ state = next((s for s in _PRECEDENCE if s in self.states), NONE)
132
+ if state is NONE and not self.evidence:
133
+ self.note(NONE, f"no gain map signal in the {container} metadata")
134
+ fired = tuple(s for s in _PRECEDENCE if s in self.states)
135
+ return state, tuple(self.evidence), self.states.get(state), fired
136
+
137
+
138
+ def classify(path: str | os.PathLike[str]) -> FileReport:
139
+ """Read ``path`` once and report which gain map flavour, if any, it carries."""
140
+ resolved = Path(path)
141
+ try:
142
+ window = FileWindow(resolved)
143
+ except FileError as exc:
144
+ return FileReport(resolved, ERROR, "unknown", 0, error=str(exc))
145
+
146
+ with window:
147
+ if window.size == 0:
148
+ return FileReport(resolved, ERROR, "unknown", 0, error="empty file")
149
+ head = window.read_at(0, 16)
150
+ try:
151
+ if jpeg.is_jpeg(head):
152
+ return _classify_jpeg(window, resolved)
153
+ if isobmff.is_isobmff(head):
154
+ return _classify_isobmff(window, resolved)
155
+ except (jpeg.JpegError, isobmff.BoxError) as exc:
156
+ return FileReport(resolved, ERROR, _container_of(head), window.size, error=str(exc))
157
+ except FileError as exc:
158
+ return FileReport(resolved, ERROR, _container_of(head), window.size, error=str(exc))
159
+ return FileReport(
160
+ resolved,
161
+ ERROR,
162
+ "unknown",
163
+ window.size,
164
+ error="not a JPEG, HEIC or AVIF file",
165
+ )
166
+
167
+
168
+ def _container_of(head: bytes) -> str:
169
+ if jpeg.is_jpeg(head):
170
+ return "jpeg"
171
+ if isobmff.is_isobmff(head):
172
+ return "isobmff"
173
+ return "unknown"
174
+
175
+
176
+ def _classify_jpeg(window: FileWindow, path: Path) -> FileReport:
177
+ images = jpeg.walk(window)
178
+ primary = images[0]
179
+ found = _Findings()
180
+
181
+ index = jpeg.find_mpf(primary)
182
+ if index is not None:
183
+ found.note(
184
+ "mpf",
185
+ f"MPF index lists {index.count} images"
186
+ + "".join(
187
+ f"; image {i.index} type 0x{i.mp_type:06x} at {i.offset} ({i.size} bytes)"
188
+ for i in index.images
189
+ ),
190
+ index.offset,
191
+ )
192
+
193
+ primary_xmp = [xmp.Xmp(p.text) for p in jpeg.collect_xmp(primary)]
194
+ secondary_xmp = [
195
+ (image, xmp.Xmp(packet.text))
196
+ for image in images[1:]
197
+ for packet in jpeg.collect_xmp(image)
198
+ ]
199
+
200
+ _rule_ultrahdr(found, primary, primary_xmp, index)
201
+ _rule_iso_jpeg(found, primary)
202
+ _rule_apple_jpeg(found, secondary_xmp, index)
203
+ _rule_orphaned(found, index, secondary_xmp)
204
+
205
+ state, evidence, gain_map, fired = found.resolve("JPEG")
206
+ return FileReport(path, state, "jpeg", window.size, evidence, gain_map, None, fired)
207
+
208
+
209
+ def _rule_ultrahdr(
210
+ found: _Findings,
211
+ primary: jpeg.JpegImage,
212
+ packets: list[xmp.Xmp],
213
+ index: jpeg.MpfIndex | None,
214
+ ) -> None:
215
+ """Rule 1: ``hdrgm:Version`` in the primary image's XMP identifies Ultra HDR."""
216
+ for position, packet in enumerate(packets):
217
+ version = packet.get(xmp.HDRGM_NS, "Version")
218
+ if version is None:
219
+ continue
220
+ declared = "declared" if packet.declares(xmp.HDRGM_NS) else "undeclared"
221
+ offset = jpeg.collect_xmp(primary)[position].offset
222
+ found.fire(
223
+ ULTRAHDR,
224
+ f'hdrgm:Version="{version}" in the primary XMP ({declared} namespace)',
225
+ offset,
226
+ _container_gain_map(packet, index),
227
+ )
228
+ item = packet.gain_map_item()
229
+ if item is not None:
230
+ found.note(
231
+ "gcontainer",
232
+ f'Container:Directory item Item:Semantic="GainMap" '
233
+ f"Item:Mime={item.mime or 'unset'} Item:Length={item.length}",
234
+ offset,
235
+ )
236
+ _check_container_length(found, item, index)
237
+ return
238
+
239
+
240
+ def _container_gain_map(packet: xmp.Xmp, index: jpeg.MpfIndex | None) -> GainMap | None:
241
+ item = packet.gain_map_item()
242
+ secondary = index.gain_map_candidates[0] if index and index.gain_map_candidates else None
243
+ if item is None and secondary is None:
244
+ return None
245
+ return GainMap(
246
+ source="gcontainer+mpf" if item and secondary else ("gcontainer" if item else "mpf"),
247
+ offset=secondary.offset if secondary else None,
248
+ length=(item.length if item and item.length else (secondary.size if secondary else None)),
249
+ mime=item.mime if item else "image/jpeg",
250
+ )
251
+
252
+
253
+ def _check_container_length(
254
+ found: _Findings, item: xmp.ContainerItem, index: jpeg.MpfIndex | None
255
+ ) -> None:
256
+ if item.length is None or index is None or not index.gain_map_candidates:
257
+ return
258
+ secondary = index.gain_map_candidates[0]
259
+ if item.length != secondary.size:
260
+ found.note(
261
+ "length-mismatch",
262
+ f"Item:Length={item.length} but the MPF entry is {secondary.size} bytes; "
263
+ "one of the two was rewritten without the other",
264
+ secondary.offset,
265
+ )
266
+
267
+
268
+ def _rule_iso_jpeg(found: _Findings, primary: jpeg.JpegImage) -> None:
269
+ """Rule 2: an APP2 segment identified by the ISO 21496-1 namespace literal."""
270
+ for segment in primary.segments:
271
+ if segment.marker != jpeg.APP2:
272
+ continue
273
+ if segment.payload.startswith(ISO_NAMESPACE):
274
+ found.fire(
275
+ ISO_JPEG,
276
+ "APP2 identifier urn:iso:std:iso:ts:21496:-1 "
277
+ f"({len(segment.payload) - len(ISO_NAMESPACE)} bytes of ISO 21496-1 metadata)",
278
+ segment.payload_offset,
279
+ GainMap(source="iso-app2", offset=segment.payload_offset),
280
+ )
281
+ return
282
+ if segment.payload.startswith(ISO_NAMESPACE_PREFIX):
283
+ found.fire(
284
+ ISO_JPEG,
285
+ "APP2 identifier urn:iso:std:iso:ts:21496 (pre-final prefix, not the "
286
+ "full :-1 literal)",
287
+ segment.payload_offset,
288
+ GainMap(source="iso-app2-prefix", offset=segment.payload_offset),
289
+ )
290
+ return
291
+
292
+
293
+ def _rule_apple_jpeg(
294
+ found: _Findings,
295
+ secondary_xmp: list[tuple[jpeg.JpegImage, xmp.Xmp]],
296
+ index: jpeg.MpfIndex | None,
297
+ ) -> None:
298
+ """Rule 4, JPEG half: an MPF secondary whose XMP names the Apple gain map URN."""
299
+ for image, packet in secondary_xmp:
300
+ aux_type = packet.get(xmp.APDI_NS, "AuxiliaryImageType")
301
+ gain_map_version = packet.get(xmp.HDRGAINMAP_NS, "HDRGainMapVersion")
302
+ if aux_type == isobmff.APPLE_GAIN_MAP_AUX:
303
+ detail = f"apdi:AuxiliaryImageType={aux_type} in MPF image {image.index}"
304
+ elif gain_map_version is not None:
305
+ detail = (
306
+ f"HDRGainMap:HDRGainMapVersion={gain_map_version} in MPF image {image.index}"
307
+ )
308
+ else:
309
+ continue
310
+ size = next(
311
+ (i.size for i in (index.images if index else ()) if i.index == image.index), None
312
+ )
313
+ found.fire(
314
+ APPLE_AUX,
315
+ detail,
316
+ image.offset,
317
+ GainMap(source="mpf-apple", offset=image.offset, length=size, mime="image/jpeg"),
318
+ )
319
+ return
320
+
321
+
322
+ def _rule_orphaned(
323
+ found: _Findings,
324
+ index: jpeg.MpfIndex | None,
325
+ secondary_xmp: list[tuple[jpeg.JpegImage, xmp.Xmp]],
326
+ ) -> None:
327
+ """An MPF secondary survives but nothing in the primary points a viewer at it."""
328
+ if found.states or index is None:
329
+ return
330
+ candidates = index.gain_map_candidates
331
+ if not candidates:
332
+ return
333
+ secondary = candidates[0]
334
+ stranded = next(
335
+ (img for img, packet in secondary_xmp if packet.declares(xmp.HDRGM_NS)),
336
+ None,
337
+ )
338
+ if stranded is not None:
339
+ detail = (
340
+ f"MPF image {secondary.index} still carries an hdrgm XMP packet but the "
341
+ "primary XMP has no hdrgm:Version, so viewers render SDR"
342
+ )
343
+ else:
344
+ detail = (
345
+ f"MPF lists {index.count} images and image {secondary.index} is not a thumbnail "
346
+ f"(type 0x{secondary.mp_type:06x}), but no gain map metadata reaches a viewer"
347
+ )
348
+ found.fire(
349
+ ORPHANED,
350
+ detail,
351
+ secondary.offset,
352
+ GainMap(source="mpf-orphan", offset=secondary.offset, length=secondary.size),
353
+ )
354
+
355
+
356
+ def _classify_isobmff(window: FileWindow, path: Path) -> FileReport:
357
+ found = _Findings()
358
+ compatible = isobmff.brands(window)
359
+ if compatible:
360
+ found.note("ftyp", "brands " + ", ".join(dict.fromkeys(compatible)), 0)
361
+
362
+ meta = isobmff.read_meta(window)
363
+ if meta is None:
364
+ _rule_apple_urn_scan(found, window, "no meta box could be walked")
365
+ else:
366
+ _rule_iso_heif(found, meta, compatible)
367
+ _rule_apple_heif(found, meta)
368
+ if not found.states:
369
+ _rule_apple_urn_scan(found, window, "meta box carries no tmap item or gain map auxC")
370
+
371
+ state, evidence, gain_map, fired = found.resolve("ISOBMFF")
372
+ return FileReport(path, state, "isobmff", window.size, evidence, gain_map, None, fired)
373
+
374
+
375
+ def _rule_iso_heif(
376
+ found: _Findings, meta: isobmff.MetaBox, compatible: tuple[str, ...]
377
+ ) -> None:
378
+ """Rule 3: a ``tmap`` derived item, normally linked by ``iref`` ``dimg``."""
379
+ for item in meta.items_of_type(isobmff.TMAP_ITEM_TYPE):
380
+ inputs = meta.references_from(item.item_id, "dimg")
381
+ detail = f"iinf item {item.item_id} has item_type tmap"
382
+ if inputs:
383
+ names = ", ".join(str(i) for i in inputs)
384
+ detail += f", iref dimg -> base and gain map items {names}"
385
+ else:
386
+ detail += " but no iref dimg links it to a base image"
387
+ if isobmff.TMAP_ITEM_TYPE in compatible:
388
+ detail += "; ftyp declares the tmap brand"
389
+ gain_input = inputs[1] if len(inputs) > 1 else None
390
+ found.fire(
391
+ ISO_HEIF,
392
+ detail,
393
+ meta.offset,
394
+ GainMap(source="tmap-item", item_id=gain_input or item.item_id),
395
+ )
396
+ return
397
+
398
+
399
+ def _rule_apple_heif(found: _Findings, meta: isobmff.MetaBox) -> None:
400
+ """Rule 4, HEIF half: an ``auxC`` naming the Apple gain map URN.
401
+
402
+ A file can carry more than one auxiliary image (a depth map alongside a
403
+ gain map is common on Portrait-mode HDR photos), so the item that
404
+ actually owns the gain-map auxC is resolved through ipco/ipma, not
405
+ guessed as "whichever item has some auxl reference".
406
+ """
407
+ if isobmff.APPLE_GAIN_MAP_AUX not in meta.aux_types:
408
+ return
409
+ linked = meta.items_with_aux_type(isobmff.APPLE_GAIN_MAP_AUX)
410
+ if linked:
411
+ gain_item: int | None = linked[0]
412
+ detail = f"ipma links item {gain_item} to auxC aux_type {isobmff.APPLE_GAIN_MAP_AUX}"
413
+ else:
414
+ # No ipma association resolved (writer omitted it, or it didn't parse):
415
+ # fall back to the first item with any outbound auxl reference at all.
416
+ fallback = [
417
+ item.item_id for item in meta.items if meta.references_from(item.item_id, "auxl")
418
+ ]
419
+ gain_item = fallback[0] if fallback else None
420
+ detail = f"auxC aux_type {isobmff.APPLE_GAIN_MAP_AUX} under iprp>ipco, no ipma association"
421
+ if gain_item is not None:
422
+ detail += f"; guessing item {gain_item} from its auxl reference"
423
+ base_items = meta.references_from(gain_item, "auxl") if gain_item is not None else ()
424
+ if base_items:
425
+ detail += f"; iref auxl -> base item {base_items[0]}"
426
+ elif linked:
427
+ detail += "; no iref auxl links it to a base item"
428
+ found.fire(
429
+ APPLE_AUX,
430
+ detail,
431
+ meta.offset,
432
+ GainMap(source="auxc-item", item_id=gain_item),
433
+ )
434
+
435
+
436
+ def _rule_apple_urn_scan(found: _Findings, window: FileWindow, why: str) -> None:
437
+ """Literal fallback for layouts the box walker cannot make sense of."""
438
+ hit = isobmff.scan_meta_for(window, isobmff.APPLE_GAIN_MAP_AUX.encode("ascii"))
439
+ if hit == -1:
440
+ found.note("urn-scan", f"{why}; the Apple gain map URN is absent too")
441
+ return
442
+ found.fire(
443
+ APPLE_AUX,
444
+ f"{why}; found the literal {isobmff.APPLE_GAIN_MAP_AUX} by byte scan",
445
+ hit,
446
+ GainMap(source="urn-scan", offset=hit),
447
+ )
448
+ found.note("urn-scan", "state came from a literal byte scan, not a parsed box tree", hit)