talax 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.
- talax/__init__.py +48 -0
- talax/__main__.py +5 -0
- talax/analysis.py +52 -0
- talax/cli.py +140 -0
- talax/clock.py +72 -0
- talax/data/editors.yaml +19 -0
- talax/data/fingerprints/samsung-gallery-edit.yaml +36 -0
- talax/data/fingerprints/whatsapp-gallery.yaml +39 -0
- talax/data/vendor_groups.yaml +14 -0
- talax/extraction.py +107 -0
- talax/fileinfo.py +90 -0
- talax/fingerprints.py +183 -0
- talax/integrity.py +133 -0
- talax/jpeg.py +137 -0
- talax/model.py +163 -0
- talax/provenance.py +53 -0
- talax/py.typed +0 -0
- talax/report.py +168 -0
- talax/signals/__init__.py +31 -0
- talax/signals/base.py +63 -0
- talax/signals/c2pa_credentials.py +129 -0
- talax/signals/capture_time.py +143 -0
- talax/signals/gps.py +101 -0
- talax/signals/real_format.py +86 -0
- talax/signals/software.py +57 -0
- talax/signals/solar_consistency.py +139 -0
- talax/signals/time_zone.py +88 -0
- talax/solar.py +149 -0
- talax/templates/report.html.j2 +138 -0
- talax/traits.py +123 -0
- talax-0.1.0.dist-info/METADATA +192 -0
- talax-0.1.0.dist-info/RECORD +35 -0
- talax-0.1.0.dist-info/WHEEL +4 -0
- talax-0.1.0.dist-info/entry_points.txt +2 -0
- talax-0.1.0.dist-info/licenses/LICENSE +202 -0
talax/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Tala X — see beyond the pixel.
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from talax import analyze
|
|
5
|
+
|
|
6
|
+
analysis = analyze(Path("photo.jpg"))
|
|
7
|
+
print(analysis.integrity.verdict)
|
|
8
|
+
for result in analysis.results:
|
|
9
|
+
print(result.signal_id, result.state, result.explanation)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from importlib.metadata import version as _version
|
|
13
|
+
|
|
14
|
+
from talax.analysis import analyze
|
|
15
|
+
from talax.model import (
|
|
16
|
+
Analysis,
|
|
17
|
+
Context,
|
|
18
|
+
Declaration,
|
|
19
|
+
FileInfo,
|
|
20
|
+
IntegrityAssessment,
|
|
21
|
+
IntegrityVerdict,
|
|
22
|
+
Metadata,
|
|
23
|
+
Observation,
|
|
24
|
+
Result,
|
|
25
|
+
Sky,
|
|
26
|
+
State,
|
|
27
|
+
)
|
|
28
|
+
from talax.signals import Signal, run_signals
|
|
29
|
+
|
|
30
|
+
__version__ = _version("talax")
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"Analysis",
|
|
34
|
+
"Context",
|
|
35
|
+
"Declaration",
|
|
36
|
+
"FileInfo",
|
|
37
|
+
"IntegrityAssessment",
|
|
38
|
+
"IntegrityVerdict",
|
|
39
|
+
"Metadata",
|
|
40
|
+
"Observation",
|
|
41
|
+
"Result",
|
|
42
|
+
"Signal",
|
|
43
|
+
"Sky",
|
|
44
|
+
"State",
|
|
45
|
+
"__version__",
|
|
46
|
+
"analyze",
|
|
47
|
+
"run_signals",
|
|
48
|
+
]
|
talax/__main__.py
ADDED
talax/analysis.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Builds the context of a file and runs the signals on it."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from talax.extraction import read_metadata
|
|
10
|
+
from talax.fileinfo import read_file_info
|
|
11
|
+
from talax.integrity import assess_integrity
|
|
12
|
+
from talax.jpeg import JpegError, JpegStructure, read_jpeg_structure
|
|
13
|
+
from talax.model import Analysis, Context, Declaration, Observation
|
|
14
|
+
from talax.provenance import read_c2pa
|
|
15
|
+
from talax.signals import DEFAULT_SIGNALS, Signal, run_signals
|
|
16
|
+
from talax.traits import read_traits
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def analyze(
|
|
20
|
+
path: Path,
|
|
21
|
+
declaration: Declaration | None = None,
|
|
22
|
+
observation: Observation | None = None,
|
|
23
|
+
signals: Iterable[Signal] = DEFAULT_SIGNALS,
|
|
24
|
+
) -> Analysis:
|
|
25
|
+
file = read_file_info(path)
|
|
26
|
+
metadata = read_metadata(path)
|
|
27
|
+
traits = read_traits(file, metadata, _jpeg_structure(path, file.real_format))
|
|
28
|
+
ctx = Context(
|
|
29
|
+
file=file,
|
|
30
|
+
metadata=metadata,
|
|
31
|
+
integrity=assess_integrity(traits),
|
|
32
|
+
declaration=declaration,
|
|
33
|
+
c2pa=read_c2pa(path),
|
|
34
|
+
observation=observation,
|
|
35
|
+
)
|
|
36
|
+
return Analysis(
|
|
37
|
+
file=file,
|
|
38
|
+
integrity=ctx.integrity,
|
|
39
|
+
results=run_signals(ctx, signals),
|
|
40
|
+
created_at=datetime.now(UTC),
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _jpeg_structure(path: Path, real_format: str | None) -> JpegStructure | None:
|
|
45
|
+
if real_format != "image/jpeg":
|
|
46
|
+
return None
|
|
47
|
+
try:
|
|
48
|
+
return read_jpeg_structure(path)
|
|
49
|
+
except JpegError:
|
|
50
|
+
# A damaged structure leaves the generic rules with less to go on;
|
|
51
|
+
# it is not a reason to stop the analysis.
|
|
52
|
+
return None
|
talax/cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Command-line interface: a thin adapter over the analysis core."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Annotated
|
|
10
|
+
|
|
11
|
+
import typer
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from talax.analysis import analyze
|
|
17
|
+
from talax.extraction import ExifToolError
|
|
18
|
+
from talax.model import Analysis, Observation, Sky, State
|
|
19
|
+
from talax.report import DISCLAIMER, STATE_LABELS, VERDICT_LABELS, render_html, to_dict
|
|
20
|
+
|
|
21
|
+
BANNER = r"""
|
|
22
|
+
_____ _ __ __
|
|
23
|
+
|_ _| __ _ | | __ _ \ \/ /
|
|
24
|
+
| | / _` || | / _` | \ /
|
|
25
|
+
| | | (_| || || (_| | / \
|
|
26
|
+
|_| \__,_||_| \__,_| /_/\_\
|
|
27
|
+
|
|
28
|
+
See beyond the pixel
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
_CONTROL = re.compile(r"[\x00-\x09\x0b-\x1f\x7f-\x9f]")
|
|
32
|
+
|
|
33
|
+
_STYLES = {
|
|
34
|
+
State.CONSISTENT: "green",
|
|
35
|
+
State.TO_CHECK: "yellow",
|
|
36
|
+
State.SUSPICIOUS: "bold red",
|
|
37
|
+
State.UNVERIFIABLE: "dim",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
app = typer.Typer(
|
|
41
|
+
help="Tala X: what can honestly be said about an image.",
|
|
42
|
+
add_completion=False,
|
|
43
|
+
)
|
|
44
|
+
out = Console()
|
|
45
|
+
err = Console(stderr=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@app.callback(invoke_without_command=True)
|
|
49
|
+
def main(ctx: typer.Context) -> None:
|
|
50
|
+
"""Tala X: what can honestly be said about an image."""
|
|
51
|
+
if ctx.invoked_subcommand is None:
|
|
52
|
+
if sys.stdout.isatty():
|
|
53
|
+
out.print(BANNER, highlight=False, markup=False)
|
|
54
|
+
typer.echo(ctx.get_help())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@app.command(name="analyze")
|
|
58
|
+
def analyze_command(
|
|
59
|
+
file: Annotated[
|
|
60
|
+
Path,
|
|
61
|
+
typer.Argument(exists=True, dir_okay=False, readable=True, help="Image file."),
|
|
62
|
+
],
|
|
63
|
+
sky: Annotated[
|
|
64
|
+
Sky | None,
|
|
65
|
+
typer.Option(help="What the photo shows, for the solar check."),
|
|
66
|
+
] = None,
|
|
67
|
+
json_output: Annotated[
|
|
68
|
+
bool, typer.Option("--json", help="Print the analysis as JSON.")
|
|
69
|
+
] = False,
|
|
70
|
+
html: Annotated[
|
|
71
|
+
Path | None,
|
|
72
|
+
typer.Option(dir_okay=False, help="Also write a self-contained HTML report."),
|
|
73
|
+
] = None,
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Analyse one file. Nothing is sent over the network."""
|
|
76
|
+
try:
|
|
77
|
+
analysis = analyze(file, observation=Observation(sky) if sky else None)
|
|
78
|
+
except (OSError, ExifToolError) as exc:
|
|
79
|
+
err.print(_safe(f"error: {exc}"), style="red")
|
|
80
|
+
raise typer.Exit(1) from exc
|
|
81
|
+
|
|
82
|
+
if html is not None:
|
|
83
|
+
html.write_text(render_html(analysis, file), encoding="utf-8")
|
|
84
|
+
|
|
85
|
+
if json_output:
|
|
86
|
+
print(json.dumps(to_dict(analysis, file.name), indent=2))
|
|
87
|
+
else:
|
|
88
|
+
_print_text(analysis, file, sky is not None)
|
|
89
|
+
if html is not None:
|
|
90
|
+
err.print(_safe(f"HTML report written to {html}"))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@app.command()
|
|
94
|
+
def version() -> None:
|
|
95
|
+
"""Show the versions of Tala X and ExifTool."""
|
|
96
|
+
from talax.report import tool_versions
|
|
97
|
+
|
|
98
|
+
versions = tool_versions()
|
|
99
|
+
print(f"talax {versions['talax']} (ExifTool {versions['exiftool']})")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _safe(text: str) -> Text:
|
|
103
|
+
"""File metadata is written by anyone: no markup, no terminal control codes."""
|
|
104
|
+
return Text(_CONTROL.sub("", text))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _print_text(analysis: Analysis, file: Path, sky_given: bool) -> None:
|
|
108
|
+
if sys.stdout.isatty():
|
|
109
|
+
out.print(BANNER, highlight=False, markup=False)
|
|
110
|
+
f = analysis.file
|
|
111
|
+
out.print(
|
|
112
|
+
_safe(f"{file.name} {f.size:,} bytes {f.real_format or ''}"), style="bold"
|
|
113
|
+
)
|
|
114
|
+
out.print(_safe(f"SHA-256 {f.sha256}"), style="dim")
|
|
115
|
+
out.print()
|
|
116
|
+
verdict = VERDICT_LABELS[analysis.integrity.verdict]
|
|
117
|
+
out.print(_safe(f"Integrity: {verdict}"), style="bold")
|
|
118
|
+
out.print(_safe(analysis.integrity.explanation))
|
|
119
|
+
out.print()
|
|
120
|
+
|
|
121
|
+
table = Table(box=None, show_header=False, padding=(0, 1, 1, 0), expand=True)
|
|
122
|
+
table.add_column(no_wrap=True)
|
|
123
|
+
table.add_column(ratio=1)
|
|
124
|
+
for r in analysis.results:
|
|
125
|
+
body = _safe(r.signal_id)
|
|
126
|
+
body.stylize("bold")
|
|
127
|
+
body.append("\n")
|
|
128
|
+
body.append_text(_safe(r.explanation))
|
|
129
|
+
table.add_row(Text(STATE_LABELS[r.state], style=_STYLES[r.state]), body)
|
|
130
|
+
out.print(table)
|
|
131
|
+
|
|
132
|
+
if not sky_given and any(
|
|
133
|
+
r.signal_id == "solar" and "sun was" in r.explanation for r in analysis.results
|
|
134
|
+
):
|
|
135
|
+
out.print(
|
|
136
|
+
"Tip: add --sky day|twilight|night to check the sun against the photo.",
|
|
137
|
+
style="cyan",
|
|
138
|
+
markup=False,
|
|
139
|
+
)
|
|
140
|
+
out.print(_safe(DISCLAIMER), style="dim")
|
talax/clock.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Reading the date and time fields ExifTool returns."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from datetime import UTC, datetime, timedelta, timezone
|
|
7
|
+
|
|
8
|
+
from talax.model import Metadata
|
|
9
|
+
|
|
10
|
+
_STAMP = re.compile(
|
|
11
|
+
r"^(\d{4}):(\d{2}):(\d{2}) (\d{2}):(\d{2}):(\d{2})(\.\d+)?"
|
|
12
|
+
r"(Z|[+-]\d{2}:\d{2})?$"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse(value: object) -> datetime | None:
|
|
17
|
+
if not isinstance(value, str):
|
|
18
|
+
return None
|
|
19
|
+
m = _STAMP.match(value.strip())
|
|
20
|
+
if not m:
|
|
21
|
+
return None
|
|
22
|
+
y, mo, d, h, mi, s, frac, tz = m.groups()
|
|
23
|
+
try:
|
|
24
|
+
dt = datetime(int(y), int(mo), int(d), int(h), int(mi), int(s))
|
|
25
|
+
except ValueError:
|
|
26
|
+
return None
|
|
27
|
+
if frac:
|
|
28
|
+
dt += timedelta(seconds=float(frac))
|
|
29
|
+
if tz == "Z":
|
|
30
|
+
return dt.replace(tzinfo=UTC)
|
|
31
|
+
if tz:
|
|
32
|
+
return dt.replace(tzinfo=offset_from_text(tz))
|
|
33
|
+
return dt
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def local(tags: Metadata, key: str, subsec_key: str | None = None) -> datetime | None:
|
|
37
|
+
dt = parse(tags.get(key))
|
|
38
|
+
if dt is None:
|
|
39
|
+
return None
|
|
40
|
+
dt = dt.replace(tzinfo=None)
|
|
41
|
+
subsec = tags.get(subsec_key) if subsec_key else None
|
|
42
|
+
if isinstance(subsec, int | str) and str(subsec).isdigit():
|
|
43
|
+
dt += timedelta(seconds=float(f"0.{subsec}"))
|
|
44
|
+
return dt
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def recorded_offset(
|
|
48
|
+
tags: Metadata, vendor: datetime | None = None
|
|
49
|
+
) -> tuple[timezone | None, str | None]:
|
|
50
|
+
"""UTC offset recorded by the device, and where it was found."""
|
|
51
|
+
if vendor is None:
|
|
52
|
+
vendor = parse(tags.get("Samsung:TimeStamp"))
|
|
53
|
+
raw = tags.get("ExifIFD:OffsetTimeOriginal")
|
|
54
|
+
if isinstance(raw, str) and re.fullmatch(r"[+-]\d{2}:\d{2}", raw.strip()):
|
|
55
|
+
return offset_from_text(raw.strip()), "the EXIF offset"
|
|
56
|
+
if vendor is not None and isinstance(vendor.tzinfo, timezone):
|
|
57
|
+
return vendor.tzinfo, "the manufacturer's clock"
|
|
58
|
+
return None, None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def offset_from_text(text: str) -> timezone:
|
|
62
|
+
sign = -1 if text[0] == "-" else 1
|
|
63
|
+
hours, minutes = int(text[1:3]), int(text[4:6])
|
|
64
|
+
return timezone(sign * timedelta(hours=hours, minutes=minutes))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def format_offset(tz: timezone | None) -> str | None:
|
|
68
|
+
if tz is None:
|
|
69
|
+
return None
|
|
70
|
+
total = int(tz.utcoffset(None).total_seconds() // 60)
|
|
71
|
+
sign = "-" if total < 0 else "+"
|
|
72
|
+
return f"{sign}{abs(total) // 60:02d}:{abs(total) % 60:02d}"
|
talax/data/editors.yaml
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Image editors recognised in software tags (IFD0:Software,
|
|
2
|
+
# XMP-xmp:CreatorTool, XMP-xmpMM:HistorySoftwareAgent).
|
|
3
|
+
# Matching is a case-insensitive substring search on each entry.
|
|
4
|
+
# Add an editor only after seeing the exact string it writes.
|
|
5
|
+
|
|
6
|
+
editors:
|
|
7
|
+
- Adobe Photoshop
|
|
8
|
+
- Adobe Lightroom
|
|
9
|
+
- Lightroom
|
|
10
|
+
- GIMP
|
|
11
|
+
- Snapseed
|
|
12
|
+
- PicsArt
|
|
13
|
+
- Canva
|
|
14
|
+
- Pixelmator
|
|
15
|
+
- Affinity Photo
|
|
16
|
+
- Paint.NET
|
|
17
|
+
- darktable
|
|
18
|
+
- Capture One
|
|
19
|
+
- Luminar
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Fingerprint of a photo edited and saved in the Samsung Gallery app.
|
|
2
|
+
# Format: see src/talax/fingerprints.py. Every field below is required.
|
|
3
|
+
|
|
4
|
+
id: samsung-gallery-edit
|
|
5
|
+
platform: Samsung Gallery
|
|
6
|
+
route: photo edited and saved on the phone
|
|
7
|
+
summary: >-
|
|
8
|
+
the camera metadata was kept, but the image was re-encoded by the Samsung
|
|
9
|
+
Gallery editor (JFIF header added, byte order changed, embedded preview and
|
|
10
|
+
network country code removed). The content may have been changed in any
|
|
11
|
+
way the editor allows: filters, stickers, text, object removal.
|
|
12
|
+
verdict: undetermined
|
|
13
|
+
|
|
14
|
+
match:
|
|
15
|
+
formats: [image/jpeg]
|
|
16
|
+
exif: present
|
|
17
|
+
vendor_data: present
|
|
18
|
+
progressive: false
|
|
19
|
+
segments: [SOI, APP1:Exif, APP0:JFIF, DQT, DQT, SOF0, DHT, DHT, DHT, DHT, DRI, SOS]
|
|
20
|
+
tags:
|
|
21
|
+
File:ExifByteOrder: MM
|
|
22
|
+
|
|
23
|
+
# Not part of the match, but observed on all 4 samples: no IFD1 (embedded
|
|
24
|
+
# preview removed), Samsung:MCCData removed, Samsung:TimeStamp kept, pixels
|
|
25
|
+
# rotated upright (orientation normal). Originals from the same phone are
|
|
26
|
+
# little-endian (II) and have no JFIF header.
|
|
27
|
+
|
|
28
|
+
measured:
|
|
29
|
+
date: 2026-10-31
|
|
30
|
+
samples: 4
|
|
31
|
+
devices: [samsung Galaxy A34 5G]
|
|
32
|
+
app_versions: [unknown]
|
|
33
|
+
retrieval: bluetooth (2 files) and usb-cable (2 files)
|
|
34
|
+
corpus_sets: [2026-10-31-a34-edits]
|
|
35
|
+
|
|
36
|
+
distinguished_from: []
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Fingerprint of a photo sent through WhatsApp as a gallery image.
|
|
2
|
+
# Format: see src/talax/fingerprints.py. Every field below is required.
|
|
3
|
+
|
|
4
|
+
id: whatsapp-gallery
|
|
5
|
+
platform: WhatsApp
|
|
6
|
+
route: photo sent from the gallery (standard quality)
|
|
7
|
+
summary: >-
|
|
8
|
+
the camera metadata is gone and the image was re-encoded the way WhatsApp
|
|
9
|
+
does it.
|
|
10
|
+
verdict: reprocessed
|
|
11
|
+
|
|
12
|
+
# All conditions must hold for a file to match.
|
|
13
|
+
match:
|
|
14
|
+
formats: [image/jpeg]
|
|
15
|
+
exif: absent
|
|
16
|
+
vendor_data: absent
|
|
17
|
+
progressive: true
|
|
18
|
+
segments: [SOI, APP0:JFIF, DQT, SOF2, DHT, SOS]
|
|
19
|
+
tags:
|
|
20
|
+
JFIF:JFIFVersion: "1 1"
|
|
21
|
+
JFIF:ResolutionUnit: 0
|
|
22
|
+
JFIF:XResolution: 1
|
|
23
|
+
JFIF:YResolution: 1
|
|
24
|
+
|
|
25
|
+
# Not part of the match: quantisation tables differ between the two devices
|
|
26
|
+
# measured (first coefficient 10 from the A34, 6 from the S22), and so do the
|
|
27
|
+
# output dimensions (1000 or 1080 px long side from the A34, 1280 from the S22).
|
|
28
|
+
|
|
29
|
+
measured:
|
|
30
|
+
date: 2026-10-31
|
|
31
|
+
samples: 13
|
|
32
|
+
devices: [samsung Galaxy A34 5G, samsung Galaxy S22 (SM-S901B)]
|
|
33
|
+
app_versions: [unknown]
|
|
34
|
+
retrieval: WhatsApp Web download
|
|
35
|
+
corpus_sets: [2026-10-31-a34, 2026-10-31-s22]
|
|
36
|
+
|
|
37
|
+
# Platforms measured that do NOT match this fingerprint. Empty means no other
|
|
38
|
+
# platform has been measured yet, and the report must say so.
|
|
39
|
+
distinguished_from: []
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# ExifTool groups (-G1 names) that only a device manufacturer writes.
|
|
2
|
+
# Their presence is one of the marks of a camera original.
|
|
3
|
+
#
|
|
4
|
+
# Listed only after being observed in a camera original of the corpus: an
|
|
5
|
+
# unknown group is NOT treated as manufacturer data, so an unmeasured device
|
|
6
|
+
# gives "undetermined", never a false "original". (Windows Photos adds the
|
|
7
|
+
# generic XMP-rdf block, which an earlier "everything not standard" rule
|
|
8
|
+
# wrongly counted.)
|
|
9
|
+
|
|
10
|
+
vendor_groups:
|
|
11
|
+
- Samsung # Samsung trailer (A34, S22)
|
|
12
|
+
- Google # Google container data (S22 motion photo)
|
|
13
|
+
- XMP-GCamera # S22
|
|
14
|
+
- XMP-GContainer # S22
|
talax/extraction.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""Metadata extraction through ExifTool, an external program."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import functools
|
|
6
|
+
import json
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from talax.model import Metadata
|
|
13
|
+
|
|
14
|
+
MIN_EXIFTOOL_VERSION = (13, 0)
|
|
15
|
+
_TIMEOUT_SECONDS = 60
|
|
16
|
+
|
|
17
|
+
# -G1: precise group (`ExifIFD:DateTimeOriginal`), so a tag forged in another
|
|
18
|
+
# block shows up separately. -a: keep duplicates. -n: raw numeric values.
|
|
19
|
+
# System tags (file name, directory, dates, permissions) describe the local
|
|
20
|
+
# copy, not the content, and may leak private paths: excluded.
|
|
21
|
+
_ARGS = ("-json", "-G1", "-a", "-n", "--System:all")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ExifToolError(RuntimeError):
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ExifToolMissingError(ExifToolError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@functools.cache
|
|
33
|
+
def exiftool_version() -> tuple[int, int]:
|
|
34
|
+
"""Installed ExifTool version. Raises if missing or too old."""
|
|
35
|
+
if shutil.which("exiftool") is None:
|
|
36
|
+
raise ExifToolMissingError(
|
|
37
|
+
"ExifTool is required but was not found. Install it from "
|
|
38
|
+
"https://exiftool.org/ and make sure `exiftool` is on your PATH."
|
|
39
|
+
)
|
|
40
|
+
out = _run(["-ver"]).strip()
|
|
41
|
+
try:
|
|
42
|
+
major, minor = (int(part) for part in out.split(".")[:2])
|
|
43
|
+
except ValueError as exc:
|
|
44
|
+
raise ExifToolError(f"unexpected ExifTool version output: {out!r}") from exc
|
|
45
|
+
if (major, minor) < MIN_EXIFTOOL_VERSION:
|
|
46
|
+
wanted = ".".join(map(str, MIN_EXIFTOOL_VERSION))
|
|
47
|
+
raise ExifToolError(
|
|
48
|
+
f"ExifTool {out} is too old; Tala X needs {wanted} or newer. Older "
|
|
49
|
+
"versions miss vendor tags and would give different results."
|
|
50
|
+
)
|
|
51
|
+
return major, minor
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def read_metadata(path: Path) -> Metadata:
|
|
55
|
+
exiftool_version()
|
|
56
|
+
# An absolute path cannot be mistaken for an option (`-name.jpg`).
|
|
57
|
+
out = _run([*_ARGS, str(path.resolve())])
|
|
58
|
+
try:
|
|
59
|
+
(record,) = json.loads(out, object_pairs_hook=_keep_duplicates)
|
|
60
|
+
except ValueError as exc:
|
|
61
|
+
raise ExifToolError("could not parse ExifTool output") from exc
|
|
62
|
+
record.pop("SourceFile", None) # local path: private, not content
|
|
63
|
+
return Metadata(record)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def read_thumbnail(path: Path) -> bytes | None:
|
|
67
|
+
"""Embedded EXIF preview image (IFD1), or None if the file has none."""
|
|
68
|
+
exiftool_version()
|
|
69
|
+
try:
|
|
70
|
+
proc = subprocess.run(
|
|
71
|
+
["exiftool", "-b", "-ThumbnailImage", str(path.resolve())],
|
|
72
|
+
capture_output=True,
|
|
73
|
+
timeout=_TIMEOUT_SECONDS,
|
|
74
|
+
check=False,
|
|
75
|
+
)
|
|
76
|
+
except subprocess.TimeoutExpired:
|
|
77
|
+
return None
|
|
78
|
+
return proc.stdout or None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _run(args: list[str]) -> str:
|
|
82
|
+
try:
|
|
83
|
+
proc = subprocess.run(
|
|
84
|
+
["exiftool", *args],
|
|
85
|
+
capture_output=True,
|
|
86
|
+
text=True,
|
|
87
|
+
encoding="utf-8",
|
|
88
|
+
timeout=_TIMEOUT_SECONDS,
|
|
89
|
+
check=False,
|
|
90
|
+
)
|
|
91
|
+
except subprocess.TimeoutExpired as exc:
|
|
92
|
+
raise ExifToolError(f"ExifTool timed out after {_TIMEOUT_SECONDS}s") from exc
|
|
93
|
+
if proc.returncode != 0 and not proc.stdout:
|
|
94
|
+
raise ExifToolError(proc.stderr.strip() or "ExifTool failed")
|
|
95
|
+
return proc.stdout
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _keep_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
|
99
|
+
"""Same tag twice in the same group is itself evidence: never drop one."""
|
|
100
|
+
result: dict[str, Any] = {}
|
|
101
|
+
for key, value in pairs:
|
|
102
|
+
name, n = key, 1
|
|
103
|
+
while name in result:
|
|
104
|
+
n += 1
|
|
105
|
+
name = f"{key}#{n}"
|
|
106
|
+
result[name] = value
|
|
107
|
+
return result
|
talax/fileinfo.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""File identity: hash, format implied by the name, format read from the bytes."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from talax.model import FileInfo
|
|
9
|
+
|
|
10
|
+
_CHUNK = 1024 * 1024
|
|
11
|
+
_SNIFF_SIZE = 32
|
|
12
|
+
|
|
13
|
+
EXTENSIONS = {
|
|
14
|
+
".jpg": "image/jpeg",
|
|
15
|
+
".jpeg": "image/jpeg",
|
|
16
|
+
".jpe": "image/jpeg",
|
|
17
|
+
".png": "image/png",
|
|
18
|
+
".gif": "image/gif",
|
|
19
|
+
".webp": "image/webp",
|
|
20
|
+
".tif": "image/tiff",
|
|
21
|
+
".tiff": "image/tiff",
|
|
22
|
+
".heic": "image/heic",
|
|
23
|
+
".heif": "image/heif",
|
|
24
|
+
".avif": "image/avif",
|
|
25
|
+
".mp4": "video/mp4",
|
|
26
|
+
".m4v": "video/mp4",
|
|
27
|
+
".mov": "video/quicktime",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
# ISO base media files (HEIC, AVIF, MP4, MOV) all start with an `ftyp` box;
|
|
31
|
+
# its major brand tells them apart.
|
|
32
|
+
_FTYP_BRANDS = {
|
|
33
|
+
b"heic": "image/heic",
|
|
34
|
+
b"heix": "image/heic",
|
|
35
|
+
b"heim": "image/heic",
|
|
36
|
+
b"heis": "image/heic",
|
|
37
|
+
b"hevc": "image/heic",
|
|
38
|
+
b"hevx": "image/heic",
|
|
39
|
+
b"mif1": "image/heif",
|
|
40
|
+
b"msf1": "image/heif",
|
|
41
|
+
b"avif": "image/avif",
|
|
42
|
+
b"avis": "image/avif",
|
|
43
|
+
b"qt ": "video/quicktime",
|
|
44
|
+
b"isom": "video/mp4",
|
|
45
|
+
b"iso2": "video/mp4",
|
|
46
|
+
b"iso4": "video/mp4",
|
|
47
|
+
b"iso5": "video/mp4",
|
|
48
|
+
b"iso6": "video/mp4",
|
|
49
|
+
b"mp41": "video/mp4",
|
|
50
|
+
b"mp42": "video/mp4",
|
|
51
|
+
b"avc1": "video/mp4",
|
|
52
|
+
b"M4V ": "video/mp4",
|
|
53
|
+
b"3gp4": "video/3gpp",
|
|
54
|
+
b"3gp5": "video/3gpp",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_file_info(path: Path) -> FileInfo:
|
|
59
|
+
digest = hashlib.sha256()
|
|
60
|
+
size = 0
|
|
61
|
+
with path.open("rb") as f:
|
|
62
|
+
head = f.read(_SNIFF_SIZE)
|
|
63
|
+
digest.update(head)
|
|
64
|
+
size += len(head)
|
|
65
|
+
while chunk := f.read(_CHUNK):
|
|
66
|
+
digest.update(chunk)
|
|
67
|
+
size += len(chunk)
|
|
68
|
+
return FileInfo(
|
|
69
|
+
sha256=digest.hexdigest(),
|
|
70
|
+
size=size,
|
|
71
|
+
declared_format=EXTENSIONS.get(path.suffix.lower()),
|
|
72
|
+
real_format=sniff_format(head),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def sniff_format(head: bytes) -> str | None:
|
|
77
|
+
"""MIME type from the first bytes of a file, or None if unrecognised."""
|
|
78
|
+
if head.startswith(b"\xff\xd8\xff"):
|
|
79
|
+
return "image/jpeg"
|
|
80
|
+
if head.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
81
|
+
return "image/png"
|
|
82
|
+
if head[:6] in (b"GIF87a", b"GIF89a"):
|
|
83
|
+
return "image/gif"
|
|
84
|
+
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
|
85
|
+
return "image/webp"
|
|
86
|
+
if head[:4] in (b"II*\x00", b"MM\x00*"):
|
|
87
|
+
return "image/tiff"
|
|
88
|
+
if head[4:8] == b"ftyp":
|
|
89
|
+
return _FTYP_BRANDS.get(head[8:12])
|
|
90
|
+
return None
|