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.
- gainmap_audit/__init__.py +5 -0
- gainmap_audit/cli.py +223 -0
- gainmap_audit/detect.py +448 -0
- gainmap_audit/isobmff.py +377 -0
- gainmap_audit/jpeg.py +263 -0
- gainmap_audit/pairing.py +201 -0
- gainmap_audit/reader.py +74 -0
- gainmap_audit/report.py +141 -0
- gainmap_audit/xmp.py +130 -0
- gainmap_audit-0.1.0.dist-info/METADATA +235 -0
- gainmap_audit-0.1.0.dist-info/RECORD +14 -0
- gainmap_audit-0.1.0.dist-info/WHEEL +4 -0
- gainmap_audit-0.1.0.dist-info/entry_points.txt +2 -0
- gainmap_audit-0.1.0.dist-info/licenses/LICENSE +21 -0
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())
|
gainmap_audit/detect.py
ADDED
|
@@ -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)
|