Refactor photo metadata workflow
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
"""Photo/video record types and ExifTool metadata decoding."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from tools.filenames import naive_wall_time
|
||||
from tools.media_metadata import first_tag, parse_exif_datetime
|
||||
from tools.timezones import parse_timezone_offset
|
||||
|
||||
|
||||
IMAGE_EXTS = {".jpg", ".jpeg", ".heic", ".arw"}
|
||||
VIDEO_EXTS = {".mp4", ".mov", ".mts"}
|
||||
SUPPORTED_EXTS = IMAGE_EXTS | VIDEO_EXTS
|
||||
|
||||
IMAGE_CAPTURE_TIME_TAGS = (
|
||||
"Composite:SubSecDateTimeOriginal",
|
||||
"Composite:SubSecCreateDate",
|
||||
"EXIF:DateTimeOriginal",
|
||||
"XMP:DateTimeOriginal",
|
||||
"XMP:CreationDate",
|
||||
"XMP:CreateDate",
|
||||
"EXIF:CreateDate",
|
||||
)
|
||||
IMAGE_MODIFY_TIME_TAGS = ("Composite:SubSecModifyDate", "EXIF:ModifyDate", "XMP:ModifyDate")
|
||||
IMAGE_OFFSET_TAGS = (
|
||||
"EXIF:OffsetTimeOriginal",
|
||||
"EXIF:OffsetTimeDigitized",
|
||||
"EXIF:OffsetTime",
|
||||
)
|
||||
VIDEO_TIME_TAGS = (
|
||||
"QuickTime:MediaCreateDate",
|
||||
"QuickTime:TrackCreateDate",
|
||||
"QuickTime:CreateDate",
|
||||
"QuickTime:MediaModifyDate",
|
||||
"QuickTime:TrackModifyDate",
|
||||
"QuickTime:ModifyDate",
|
||||
"XMP:DateTimeOriginal",
|
||||
"XMP:CreateDate",
|
||||
)
|
||||
VIDEO_BEGIN_TIME_TAGS = ("H264:DateTimeOriginal",)
|
||||
VIDEO_DURATION_TAGS = (
|
||||
"QuickTime:Duration",
|
||||
"Composite:Duration",
|
||||
"File:Duration",
|
||||
"M2TS:Duration",
|
||||
)
|
||||
VIDEO_TIMEZONE_TAGS = ("QuickTime:AndroidTimeZone", "QuickTime:Keys:AndroidTimeZone")
|
||||
SEQUENCE_TAGS = (
|
||||
"MakerNotes:SequenceNumber",
|
||||
"MakerNotes:SequenceNumberOriginal",
|
||||
"MakerNotes:ImageNumber",
|
||||
"EXIF:ImageNumber",
|
||||
"SequenceNumber",
|
||||
)
|
||||
FILENAME_TIMESTAMP_RE = re.compile(r"(?P<date>\d{8})[-_](?P<time>\d{6})")
|
||||
|
||||
|
||||
@dataclass
|
||||
class XmlSidecar:
|
||||
path: Path
|
||||
creation: datetime | None = None
|
||||
duration_seconds: float | None = None
|
||||
timezone_value: timezone | None = None
|
||||
device: str | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class TagWrite:
|
||||
label: str
|
||||
arg: str
|
||||
current_value: str | None
|
||||
new_value: str
|
||||
will_write: bool
|
||||
|
||||
|
||||
@dataclass
|
||||
class MediaRecord:
|
||||
path: Path
|
||||
metadata: dict
|
||||
kind: str
|
||||
original_time: datetime | None = None
|
||||
adjusted_time: datetime | None = None
|
||||
timezone_value: timezone | None = None
|
||||
resolved_timezone: timezone | None = None
|
||||
inferred_timestamp_source: str | None = None
|
||||
subsec: int | None = None
|
||||
inferred_subsec: int | None = None
|
||||
sequence: int | None = None
|
||||
camera_key: str = ""
|
||||
duration_seconds: float | None = None
|
||||
video_time_is_beginning: bool = False
|
||||
parsed_from_filename: bool = False
|
||||
sidecar: XmlSidecar | None = None
|
||||
new_stem: str | None = None
|
||||
final_name: str | None = None
|
||||
group_id: int | None = None
|
||||
group_name: str | None = None
|
||||
target_path: Path | None = None
|
||||
target_sidecar: Path | None = None
|
||||
rename_skip_reason: str | None = None
|
||||
write_plan: list[TagWrite] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_image(self) -> bool:
|
||||
return self.kind == "image"
|
||||
|
||||
@property
|
||||
def is_video(self) -> bool:
|
||||
return self.kind == "video"
|
||||
|
||||
|
||||
def parse_subsec(value: object) -> int | None:
|
||||
match = re.search(r"\d+", str(value)) if value is not None else None
|
||||
return int(match.group(0)[:3].ljust(3, "0")) if match else None
|
||||
|
||||
|
||||
def parse_sequence(value: object) -> int | None:
|
||||
match = re.search(r"\d+", str(value)) if value is not None else None
|
||||
return int(match.group(0)) if match else None
|
||||
|
||||
|
||||
def parse_duration_seconds(value: object) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
match = re.match(r"^([0-9]+(?:\.[0-9]+)?)\s*(?:s|sec|seconds)?$", text, re.I)
|
||||
if match:
|
||||
return float(match.group(1))
|
||||
match = re.match(r"^(?:(?P<h>\d+):)?(?P<m>\d{1,2}):(?P<s>\d{1,2}(?:\.\d+)?)$", text)
|
||||
if match:
|
||||
return int(match.group("h") or 0) * 3600 + int(match.group("m")) * 60 + float(match.group("s"))
|
||||
match = re.search(r"([0-9]+(?:\.[0-9]+)?)\s*s", text, re.I)
|
||||
return float(match.group(1)) if match else None
|
||||
|
||||
|
||||
def parse_filename_timestamp(record: MediaRecord) -> datetime | None:
|
||||
return parse_filename_timestamp_text(record.path.stem)
|
||||
|
||||
|
||||
def parse_filename_timestamp_text(text: str) -> datetime | None:
|
||||
match = FILENAME_TIMESTAMP_RE.search(text)
|
||||
return parse_exif_datetime(match.group(0)) if match else None
|
||||
|
||||
|
||||
def find_sidecar(video_path: Path) -> XmlSidecar | None:
|
||||
expected = f"{video_path.stem}M01.XML"
|
||||
for child in video_path.parent.iterdir():
|
||||
if child.is_file() and child.name.lower() == expected.lower():
|
||||
return parse_xml_sidecar(child)
|
||||
return None
|
||||
|
||||
|
||||
def parse_xml_sidecar(path: Path) -> XmlSidecar:
|
||||
sidecar = XmlSidecar(path)
|
||||
try:
|
||||
root = ET.parse(path).getroot()
|
||||
except ET.ParseError as exc:
|
||||
sidecar.device = f"unreadable XML: {exc}"
|
||||
return sidecar
|
||||
|
||||
duration = fps = None
|
||||
for element in root.iter():
|
||||
name = element.tag.rsplit("}", 1)[-1]
|
||||
if name == "CreationDate" and (value := element.attrib.get("value")):
|
||||
sidecar.creation = parse_exif_datetime(value)
|
||||
if sidecar.creation and sidecar.creation.tzinfo:
|
||||
sidecar.timezone_value = sidecar.creation.tzinfo
|
||||
elif name == "Duration" and (value := element.attrib.get("value")):
|
||||
duration = parse_duration_seconds(value)
|
||||
elif name == "VideoFrame":
|
||||
match = re.search(r"\d+(?:\.\d+)?", element.attrib.get("captureFps") or element.attrib.get("formatFps") or "")
|
||||
fps = float(match.group(0)) if match else fps
|
||||
elif name == "Device":
|
||||
sidecar.device = " ".join(filter(None, (element.attrib.get("manufacturer"), element.attrib.get("modelName"))))
|
||||
sidecar.duration_seconds = duration / fps if duration is not None and fps else duration
|
||||
return sidecar
|
||||
|
||||
|
||||
def camera_key(record: MediaRecord) -> str:
|
||||
metadata = record.metadata
|
||||
make = first_tag(metadata, ("EXIF:Make", "QuickTime:Make", "MakerNotes:Make", "H264:Make")) or ""
|
||||
model = first_tag(metadata, ("EXIF:Model", "QuickTime:Model", "MakerNotes:Model", "H264:Model")) or ""
|
||||
serial = first_tag(metadata, (
|
||||
"EXIF:SerialNumber", "MakerNotes:SerialNumber", "MakerNotes:InternalSerialNumber", "Composite:SerialNumber",
|
||||
))
|
||||
return "|".join(str(part).strip() for part in (make, model, serial) if part) or f"dir:{record.path.parent.resolve()}"
|
||||
|
||||
|
||||
def infer_video_timezone(record: MediaRecord) -> timezone | None:
|
||||
if record.original_time is None or record.duration_seconds is None:
|
||||
return None
|
||||
filename_time = parse_filename_timestamp(record)
|
||||
if filename_time is None:
|
||||
return None
|
||||
begin = record.original_time - timedelta(seconds=record.duration_seconds)
|
||||
offset_seconds = (filename_time - naive_wall_time(begin.astimezone(timezone.utc))).total_seconds()
|
||||
offset_minutes = round(offset_seconds / 60)
|
||||
if abs(offset_seconds - offset_minutes * 60) > 2 or abs(offset_minutes) > 14 * 60:
|
||||
return None
|
||||
return timezone(timedelta(minutes=offset_minutes))
|
||||
|
||||
|
||||
def build_records(paths: list[Path], metadata_items: list[dict]) -> list[MediaRecord]:
|
||||
by_source = {Path(item["SourceFile"]).resolve(): item for item in metadata_items if item.get("SourceFile")}
|
||||
records: list[MediaRecord] = []
|
||||
for path in paths:
|
||||
metadata = by_source.get(path.resolve(), {})
|
||||
record = MediaRecord(path, metadata, "video" if path.suffix.lower() in VIDEO_EXTS else "image")
|
||||
if record.is_image:
|
||||
record.original_time = parse_exif_datetime(first_tag(metadata, IMAGE_CAPTURE_TIME_TAGS))
|
||||
record.adjusted_time = record.original_time
|
||||
offset = first_tag(metadata, IMAGE_OFFSET_TAGS)
|
||||
if offset:
|
||||
try:
|
||||
record.timezone_value = parse_timezone_offset(str(offset))
|
||||
except ValueError:
|
||||
record.warnings.append(f"Invalid timezone offset metadata: {offset}")
|
||||
record.subsec = parse_subsec(first_tag(metadata, (
|
||||
"EXIF:SubSecTimeOriginal", "EXIF:SubSecTimeDigitized", "EXIF:SubSecTime",
|
||||
)))
|
||||
else:
|
||||
begin = first_tag(metadata, VIDEO_BEGIN_TIME_TAGS)
|
||||
if path.suffix.lower() == ".mts" and begin is not None:
|
||||
record.original_time = parse_exif_datetime(begin, assume_utc=True)
|
||||
record.video_time_is_beginning = True
|
||||
else:
|
||||
record.original_time = parse_exif_datetime(first_tag(metadata, VIDEO_TIME_TAGS), assume_utc=True)
|
||||
record.adjusted_time = record.original_time
|
||||
record.duration_seconds = parse_duration_seconds(first_tag(metadata, VIDEO_DURATION_TAGS))
|
||||
offset = first_tag(metadata, VIDEO_TIMEZONE_TAGS)
|
||||
if offset:
|
||||
try:
|
||||
record.timezone_value = parse_timezone_offset(str(offset))
|
||||
except ValueError:
|
||||
record.warnings.append(f"Invalid video timezone metadata: {offset}")
|
||||
record.timezone_value = record.timezone_value or infer_video_timezone(record)
|
||||
record.sidecar = find_sidecar(path)
|
||||
if record.sidecar and record.duration_seconds is not None:
|
||||
xml_duration = record.sidecar.duration_seconds
|
||||
if xml_duration is not None and abs(xml_duration - record.duration_seconds) > 1:
|
||||
record.warnings.append(f"XML duration {xml_duration:g}s differs from metadata duration {record.duration_seconds:g}s")
|
||||
record.sequence = parse_sequence(first_tag(metadata, SEQUENCE_TAGS))
|
||||
record.camera_key = camera_key(record)
|
||||
records.append(record)
|
||||
return records
|
||||
Reference in New Issue
Block a user