Refactor photo metadata workflow
This commit is contained in:
+179
-30
@@ -1,14 +1,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import select
|
||||
import shutil
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
from collections import deque
|
||||
from contextlib import contextmanager
|
||||
from math import floor, log10
|
||||
from threading import Lock
|
||||
|
||||
|
||||
_WINDOWS_ANSI_ENABLED: bool | None = None
|
||||
_WINDOWS_CONSOLE_OUTPUT: bool | None = None
|
||||
|
||||
|
||||
def prompt_input(prompt: str) -> str:
|
||||
@@ -44,7 +49,7 @@ def supports_color() -> bool:
|
||||
|
||||
|
||||
def supports_ansi() -> bool:
|
||||
if not sys.stdout.isatty():
|
||||
if not stream_is_interactive(sys.stdout):
|
||||
return False
|
||||
if os.name != "nt":
|
||||
return os.environ.get("TERM", "dumb") != "dumb"
|
||||
@@ -56,6 +61,10 @@ def supports_ansi() -> bool:
|
||||
)
|
||||
|
||||
|
||||
def stream_is_interactive(stream) -> bool:
|
||||
return stream.isatty() or (os.name == "nt" and stream is sys.stdout and _windows_console_output())
|
||||
|
||||
|
||||
def color(text: object, code: str) -> str:
|
||||
value = str(text)
|
||||
if not supports_color():
|
||||
@@ -79,12 +88,74 @@ def red_strikethrough(text: object) -> str:
|
||||
return color(text, "91;9")
|
||||
|
||||
|
||||
def clear_screen() -> None:
|
||||
if not sys.stdout.isatty():
|
||||
def clear_screen(*, scrollback: bool = False) -> None:
|
||||
if not stream_is_interactive(sys.stdout):
|
||||
return
|
||||
if supports_ansi():
|
||||
sys.stdout.write("\x1b[3J" if scrollback else "")
|
||||
sys.stdout.write("\x1b[H\x1b[2J")
|
||||
sys.stdout.flush()
|
||||
return
|
||||
os.system("cls" if os.name == "nt" else "clear")
|
||||
|
||||
|
||||
def require_terminal_ui() -> None:
|
||||
if not (stream_is_interactive(sys.stdin) and stream_is_interactive(sys.stdout) and supports_ansi()):
|
||||
raise RuntimeError("This settings dialog requires an ANSI-capable interactive terminal.")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def raw_key_input():
|
||||
require_terminal_ui()
|
||||
if os.name == "nt":
|
||||
yield read_key
|
||||
return
|
||||
|
||||
import termios
|
||||
import tty
|
||||
|
||||
fd = sys.stdin.fileno()
|
||||
settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setraw(fd)
|
||||
yield read_key
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, settings)
|
||||
|
||||
|
||||
def read_key() -> str:
|
||||
if os.name == "nt":
|
||||
import msvcrt
|
||||
|
||||
key = msvcrt.getwch()
|
||||
if key in {"\x00", "\xe0"}:
|
||||
return {"H": "up", "P": "down", "K": "left", "M": "right"}.get(msvcrt.getwch(), "")
|
||||
else:
|
||||
key = sys.stdin.read(1)
|
||||
if key == "\x1b" and select.select([sys.stdin], [], [], 0.03)[0]:
|
||||
key += sys.stdin.read(2)
|
||||
return {"\x1b[A": "up", "\x1b[B": "down", "\x1b[D": "left", "\x1b[C": "right"}.get(key, "esc")
|
||||
return {"\r": "enter", "\n": "enter", "\x1b": "esc", "\x08": "backspace", "\x7f": "backspace", " ": "space"}.get(key, key)
|
||||
|
||||
|
||||
def draw_screen(lines: list[str]) -> None:
|
||||
require_terminal_ui()
|
||||
sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(lines))
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def inverse(text: str) -> str:
|
||||
return f"\x1b[30;47m{text}\x1b[0m"
|
||||
|
||||
|
||||
def dim(text: str) -> str:
|
||||
return f"\x1b[90m{text}\x1b[0m"
|
||||
|
||||
|
||||
def dark_field(text: str) -> str:
|
||||
return f"\x1b[30;100m{text}\x1b[0m"
|
||||
|
||||
|
||||
def _enable_windows_ansi() -> bool:
|
||||
global _WINDOWS_ANSI_ENABLED
|
||||
if _WINDOWS_ANSI_ENABLED is not None:
|
||||
@@ -105,6 +176,23 @@ def _enable_windows_ansi() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _windows_console_output() -> bool:
|
||||
global _WINDOWS_CONSOLE_OUTPUT
|
||||
if _WINDOWS_CONSOLE_OUTPUT is not None:
|
||||
return _WINDOWS_CONSOLE_OUTPUT
|
||||
try:
|
||||
import ctypes
|
||||
|
||||
mode = ctypes.c_uint32()
|
||||
handle = ctypes.windll.kernel32.GetStdHandle(-11)
|
||||
_WINDOWS_CONSOLE_OUTPUT = bool(
|
||||
ctypes.windll.kernel32.GetConsoleMode(handle, ctypes.byref(mode))
|
||||
)
|
||||
except Exception:
|
||||
_WINDOWS_CONSOLE_OUTPUT = False
|
||||
return _WINDOWS_CONSOLE_OUTPUT
|
||||
|
||||
|
||||
def format_duration(seconds: float) -> str:
|
||||
seconds = max(0, int(round(seconds)))
|
||||
minutes, second = divmod(seconds, 60)
|
||||
@@ -120,30 +208,42 @@ class ProgressView:
|
||||
total: int,
|
||||
label: str,
|
||||
*,
|
||||
total_work: float | None = None,
|
||||
stream=None,
|
||||
embedded_percent: bool = False,
|
||||
show_rate: bool = False,
|
||||
show_elapsed: bool = True,
|
||||
bar_width: int = 24,
|
||||
) -> None:
|
||||
self.total = max(0, total)
|
||||
self.total_work = total if total_work is None else max(0.0, total_work)
|
||||
self.label = label
|
||||
self.stream = stream or sys.stdout
|
||||
self.started_at = time.monotonic()
|
||||
self.samples: deque[tuple[int, float]] = deque()
|
||||
self.samples: deque[tuple[float, float]] = deque()
|
||||
self.last_width = 0
|
||||
self.last_line_count = 0
|
||||
self.embedded_percent = embedded_percent
|
||||
self.show_rate = show_rate
|
||||
self.show_elapsed = show_elapsed
|
||||
self.bar_width = bar_width
|
||||
|
||||
def update(self, processed: int, detail: str = "") -> None:
|
||||
self._write(self.update_line(processed, detail))
|
||||
def update(
|
||||
self, processed: int, detail: str = "", *, completed_work: float | None = None
|
||||
) -> None:
|
||||
self._write(self.update_line(processed, detail, completed_work=completed_work))
|
||||
|
||||
def update_line(self, processed: int, detail: str = "") -> str:
|
||||
def update_line(
|
||||
self, processed: int, detail: str = "", *, completed_work: float | None = None
|
||||
) -> str:
|
||||
processed = max(0, min(processed, self.total))
|
||||
work_done = min(self.total_work, completed_work if completed_work is not None else processed)
|
||||
now = time.monotonic()
|
||||
if not self.samples or self.samples[-1][0] != processed:
|
||||
self.samples.append((processed, now))
|
||||
if not self.samples or self.samples[-1][0] != work_done:
|
||||
self.samples.append((work_done, now))
|
||||
while len(self.samples) > max(2, processed // 2 + 2):
|
||||
self.samples.popleft()
|
||||
return self._line(processed, detail, now)
|
||||
return self._line(processed, detail, now, completed_work=work_done)
|
||||
|
||||
def update_external(
|
||||
self,
|
||||
@@ -167,25 +267,60 @@ class ProgressView:
|
||||
return self._line(processed, detail, None, elapsed=elapsed, eta=eta)
|
||||
|
||||
def finish(self, *, keep: bool = False) -> None:
|
||||
if self.stream.isatty():
|
||||
if stream_is_interactive(self.stream):
|
||||
if keep:
|
||||
self.stream.write("\n")
|
||||
else:
|
||||
self.stream.write("\r" + " " * self.last_width + "\r")
|
||||
self._clear_live_lines()
|
||||
self.stream.flush()
|
||||
elif self.last_width and not keep:
|
||||
self.stream.write("\n")
|
||||
self.last_width = 0
|
||||
self.last_line_count = 0
|
||||
|
||||
def _write(self, line: str) -> None:
|
||||
if self.stream.isatty():
|
||||
def write_lines(self, lines: list[str]) -> None:
|
||||
self._write(lines)
|
||||
|
||||
def _write(self, content: str | list[str]) -> None:
|
||||
lines = [content] if isinstance(content, str) else content
|
||||
if stream_is_interactive(self.stream) and supports_ansi():
|
||||
lines = [wrapped for line in lines for wrapped in _wrap_to_terminal(line, self.stream)]
|
||||
if self.last_line_count > 1:
|
||||
self.stream.write(f"\x1b[{self.last_line_count - 1}F")
|
||||
rows = max(self.last_line_count, len(lines))
|
||||
for index in range(rows):
|
||||
self.stream.write("\r\x1b[2K")
|
||||
if index < len(lines):
|
||||
self.stream.write(lines[index])
|
||||
if index < rows - 1:
|
||||
self.stream.write("\n")
|
||||
if rows > len(lines):
|
||||
self.stream.write(f"\x1b[{rows - len(lines)}F")
|
||||
self.stream.flush()
|
||||
self.last_width = max(map(len, lines), default=0)
|
||||
self.last_line_count = len(lines)
|
||||
elif stream_is_interactive(self.stream):
|
||||
line = " ".join(lines)
|
||||
padding = max(0, self.last_width - len(line))
|
||||
self.stream.write("\r" + line + (" " * padding))
|
||||
self.stream.flush()
|
||||
self.last_width = len(line)
|
||||
else:
|
||||
self.stream.write(line + "\n")
|
||||
self.last_width = len(line)
|
||||
self.stream.write("\n".join(lines) + "\n")
|
||||
self.last_width = max(map(len, lines), default=0)
|
||||
|
||||
def _clear_live_lines(self) -> None:
|
||||
if not self.last_line_count or not supports_ansi():
|
||||
self.stream.write("\r" + " " * self.last_width + "\r")
|
||||
return
|
||||
if self.last_line_count > 1:
|
||||
self.stream.write(f"\x1b[{self.last_line_count - 1}F")
|
||||
for index in range(self.last_line_count):
|
||||
self.stream.write("\r\x1b[2K")
|
||||
if index < self.last_line_count - 1:
|
||||
self.stream.write("\n")
|
||||
if self.last_line_count > 1:
|
||||
self.stream.write(f"\x1b[{self.last_line_count - 1}F")
|
||||
|
||||
def _line(
|
||||
self,
|
||||
@@ -195,13 +330,15 @@ class ProgressView:
|
||||
*,
|
||||
elapsed: float | None = None,
|
||||
eta: float | None = None,
|
||||
completed_work: float | None = None,
|
||||
) -> str:
|
||||
prefix = self.label if not detail else f"{self.label}: {detail}"
|
||||
prefix = self.label
|
||||
if self.total <= 2:
|
||||
return f"{prefix} {processed}/{self.total}"
|
||||
return " ".join(part for part in (prefix, f"{processed}/{self.total}", detail) if part)
|
||||
|
||||
percent = 0.0 if self.total == 0 else processed / self.total
|
||||
bar = _progress_bar(percent, embedded_percent=self.embedded_percent)
|
||||
work_done = processed if completed_work is None else completed_work
|
||||
percent = 0.0 if self.total_work == 0 else work_done / self.total_work
|
||||
bar = _progress_bar(percent, embedded_percent=self.embedded_percent, width=self.bar_width)
|
||||
elapsed_value = elapsed if elapsed is not None else (now or time.monotonic()) - self.started_at
|
||||
parts = [
|
||||
prefix,
|
||||
@@ -212,22 +349,25 @@ class ProgressView:
|
||||
parts.append(f"{percent * 100:5.1f}%")
|
||||
if self.show_rate and processed > 0 and elapsed_value > 0:
|
||||
parts.append(f"{_format_significant(processed / elapsed_value, 2)} fps")
|
||||
parts.append(f"elapsed {format_duration(elapsed_value)}")
|
||||
eta_value = eta if eta is not None else self._eta(processed)
|
||||
if self.show_elapsed:
|
||||
parts.append(f"elapsed {format_duration(elapsed_value)}")
|
||||
eta_value = eta if eta is not None else self._eta(processed, work_done)
|
||||
if processed >= 2 and processed < self.total and eta_value is not None:
|
||||
parts.append(f"ETA {format_duration(eta_value)}")
|
||||
if detail:
|
||||
parts.append(detail)
|
||||
return " ".join(parts)
|
||||
|
||||
def _eta(self, processed: int) -> float | None:
|
||||
def _eta(self, processed: int, work_done: float) -> float | None:
|
||||
if processed < 2 or self.total <= processed or len(self.samples) < 2:
|
||||
return None
|
||||
oldest_processed, oldest_time = self.samples[0]
|
||||
newest_processed, newest_time = self.samples[-1]
|
||||
delta_items = newest_processed - oldest_processed
|
||||
oldest_work, oldest_time = self.samples[0]
|
||||
newest_work, newest_time = self.samples[-1]
|
||||
delta_work = newest_work - oldest_work
|
||||
delta_time = newest_time - oldest_time
|
||||
if delta_items <= 0 or delta_time <= 0:
|
||||
if delta_work <= 0 or delta_time <= 0:
|
||||
return None
|
||||
return (self.total - processed) * (delta_time / delta_items)
|
||||
return (self.total_work - work_done) * (delta_time / delta_work)
|
||||
|
||||
|
||||
class PipelineProgressView:
|
||||
@@ -311,8 +451,7 @@ class PipelineProgressView:
|
||||
self._shown = True
|
||||
|
||||
|
||||
def _progress_bar(percent: float, *, embedded_percent: bool) -> str:
|
||||
width = 24
|
||||
def _progress_bar(percent: float, *, embedded_percent: bool, width: int = 24) -> str:
|
||||
done = round(max(0.0, min(percent, 1.0)) * width)
|
||||
if not embedded_percent:
|
||||
return "[" + ("#" * done).ljust(width, "-") + "]"
|
||||
@@ -335,6 +474,16 @@ def _center_progress_text(text: str, width: int, fill: str) -> str:
|
||||
return content.center(width, fill)
|
||||
|
||||
|
||||
def _wrap_to_terminal(line: str, stream) -> list[str]:
|
||||
try:
|
||||
width = shutil.get_terminal_size().columns
|
||||
except OSError:
|
||||
return [line]
|
||||
if width <= 0 or len(line) <= width:
|
||||
return [line]
|
||||
return textwrap.wrap(line, width=width, break_long_words=False, break_on_hyphens=False) or [line]
|
||||
|
||||
|
||||
def _format_significant(value: float, digits: int) -> str:
|
||||
if value == 0:
|
||||
return "0"
|
||||
|
||||
+71
-21
@@ -2,8 +2,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -18,15 +21,12 @@ def _use_windows_argument_file() -> bool:
|
||||
return os.name == "nt"
|
||||
|
||||
|
||||
def run_exiftool_command(
|
||||
executable: str,
|
||||
args: list[str],
|
||||
**run_kwargs: Any,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run ExifTool with Unicode-safe filename arguments on Windows."""
|
||||
@contextmanager
|
||||
def _prepared_command(executable: str, args: list[str]) -> Iterator[tuple[list[str], str | None]]:
|
||||
command = [executable, "-charset", "filename=UTF8", *args]
|
||||
if not _use_windows_argument_file():
|
||||
return subprocess.run(command, **run_kwargs)
|
||||
yield command, None
|
||||
return
|
||||
|
||||
# ExifTool can't reliably receive arbitrary Unicode paths on the Windows
|
||||
# command line. The outer path is ASCII and the UTF-8 argument file holds
|
||||
@@ -34,11 +34,20 @@ def run_exiftool_command(
|
||||
with tempfile.TemporaryDirectory(prefix="mbt-exiftool-") as temp_dir:
|
||||
argument_file = Path(temp_dir) / "arguments.txt"
|
||||
argument_file.write_text("\n".join(args) + "\n", encoding="utf-8")
|
||||
return subprocess.run(
|
||||
[executable, "-charset", "filename=UTF8", "-@", argument_file.name],
|
||||
cwd=temp_dir,
|
||||
**run_kwargs,
|
||||
)
|
||||
yield [executable, "-charset", "filename=UTF8", "-@", argument_file.name], temp_dir
|
||||
|
||||
|
||||
def run_exiftool_command(
|
||||
executable: str,
|
||||
args: list[str],
|
||||
**run_kwargs: Any,
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run ExifTool with Unicode-safe filename arguments on Windows."""
|
||||
with _prepared_command(executable, args) as (command, cwd):
|
||||
return subprocess.run(command, cwd=cwd, **run_kwargs)
|
||||
|
||||
|
||||
_PROGRESS_RE = re.compile(r"^======== .* \[\d+/(\d+)\]$")
|
||||
|
||||
|
||||
def run_exiftool_json(
|
||||
@@ -46,6 +55,7 @@ def run_exiftool_json(
|
||||
paths: list[Path],
|
||||
*,
|
||||
quicktime_utc: bool = True,
|
||||
progress: Callable[[], None] | None = None,
|
||||
) -> list[dict]:
|
||||
results: list[dict] = []
|
||||
for start in range(0, len(paths), 80):
|
||||
@@ -57,14 +67,45 @@ def run_exiftool_json(
|
||||
if quicktime_utc:
|
||||
args.extend(["-api", "QuickTimeUTC=1"])
|
||||
args.extend(str(path) for path in chunk)
|
||||
completed = run_exiftool_command(
|
||||
executable,
|
||||
args,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
results.extend(json.loads(completed.stdout or "[]"))
|
||||
if progress is None:
|
||||
completed = run_exiftool_command(
|
||||
executable,
|
||||
args,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
results.extend(json.loads(completed.stdout or "[]"))
|
||||
continue
|
||||
|
||||
with _prepared_command(executable, ["-progress", *args]) as (command, cwd):
|
||||
with tempfile.TemporaryFile(mode="w+t", encoding="utf-8") as output:
|
||||
process = subprocess.Popen(
|
||||
command,
|
||||
cwd=cwd,
|
||||
stdout=output,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
assert process.stderr is not None
|
||||
errors = []
|
||||
started_file = False
|
||||
for line in process.stderr:
|
||||
if _PROGRESS_RE.match(line.rstrip("\r\n")):
|
||||
# ExifTool emits this marker before reading the named
|
||||
# file, so it confirms that the previous one finished.
|
||||
if started_file:
|
||||
progress()
|
||||
started_file = True
|
||||
else:
|
||||
errors.append(line)
|
||||
return_code = process.wait()
|
||||
if return_code:
|
||||
raise subprocess.CalledProcessError(return_code, command, stderr="".join(errors))
|
||||
if started_file:
|
||||
progress()
|
||||
output.seek(0)
|
||||
results.extend(json.load(output))
|
||||
return results
|
||||
|
||||
|
||||
@@ -76,4 +117,13 @@ def run_exiftool_write(executable: str, path: Path, args: list[str]) -> None:
|
||||
*args,
|
||||
str(path),
|
||||
]
|
||||
run_exiftool_command(executable, command, check=True)
|
||||
completed = run_exiftool_command(
|
||||
executable,
|
||||
command,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if completed.returncode:
|
||||
detail = (completed.stderr or completed.stdout or "").strip()
|
||||
raise RuntimeError(f"ExifTool failed: {detail or f'exit code {completed.returncode}'}")
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Small, shared helpers for values returned by ExifTool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from collections.abc import Iterable, Mapping
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
DATETIME_RE = re.compile(
|
||||
r"(?P<Y>\d{4})[:\-]?(?P<M>\d{2})[:\-]?(?P<D>\d{2})"
|
||||
r"(?:[ T_])?"
|
||||
r"(?P<h>\d{2}):?(?P<m>\d{2}):?(?P<s>\d{2})"
|
||||
r"(?:[.,](?P<sub>\d+))?"
|
||||
r"(?:\s*(?P<tz>Z|[+-]\d{2}:?\d{2}))?"
|
||||
)
|
||||
|
||||
|
||||
def first_tag(metadata: Mapping[str, object], tags: Iterable[str]) -> object | None:
|
||||
for tag in tags:
|
||||
if tag in metadata:
|
||||
return metadata[tag]
|
||||
return None
|
||||
|
||||
|
||||
def tag_value(metadata: Mapping[str, object], tag: str) -> str | None:
|
||||
value = metadata.get(tag)
|
||||
return None if value is None else str(value)
|
||||
|
||||
|
||||
def parse_exif_datetime(value: object, assume_utc: bool = False) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
match = DATETIME_RE.search(str(value).strip())
|
||||
if not match:
|
||||
return None
|
||||
try:
|
||||
tz_text = match.group("tz")
|
||||
if tz_text == "Z":
|
||||
tzinfo = timezone.utc
|
||||
elif tz_text:
|
||||
sign = 1 if tz_text[0] == "+" else -1
|
||||
digits = tz_text[1:].replace(":", "")
|
||||
tzinfo = timezone(sign * timedelta(hours=int(digits[:2]), minutes=int(digits[2:])))
|
||||
else:
|
||||
tzinfo = None
|
||||
parsed = datetime(
|
||||
int(match.group("Y")),
|
||||
int(match.group("M")),
|
||||
int(match.group("D")),
|
||||
int(match.group("h")),
|
||||
int(match.group("m")),
|
||||
int(match.group("s")),
|
||||
tzinfo=tzinfo,
|
||||
)
|
||||
return parsed.replace(tzinfo=timezone.utc) if assume_utc and tzinfo is None else parsed
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def round_half_up(value: float) -> int:
|
||||
return math.floor(value + 0.5)
|
||||
+6
-46
@@ -1,11 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from tools.exiftool import run_exiftool_command
|
||||
from tools.filesystem import unique_path
|
||||
|
||||
|
||||
EXCLUDED_COPY_TAGS = [
|
||||
@@ -70,15 +67,12 @@ def copy_meaningful_metadata(
|
||||
*,
|
||||
extra_excluded_tags: list[str] | None = None,
|
||||
) -> None:
|
||||
if destination.suffix.lower() in {".mp4", ".mov"}:
|
||||
copy_meaningful_metadata_with_exiftool(
|
||||
exiftool,
|
||||
source,
|
||||
destination,
|
||||
extra_excluded_tags=extra_excluded_tags,
|
||||
)
|
||||
return
|
||||
copy_container_metadata_with_ffmpeg(source, destination)
|
||||
copy_meaningful_metadata_with_exiftool(
|
||||
exiftool,
|
||||
source,
|
||||
destination,
|
||||
extra_excluded_tags=extra_excluded_tags,
|
||||
)
|
||||
|
||||
|
||||
def copy_meaningful_metadata_with_exiftool(
|
||||
@@ -104,37 +98,3 @@ def copy_meaningful_metadata_with_exiftool(
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def copy_container_metadata_with_ffmpeg(source: Path, destination: Path) -> None:
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if not ffmpeg:
|
||||
print(" metadata: ffmpeg not found; skipped best-effort metadata remux")
|
||||
return
|
||||
|
||||
temp_output = destination.with_name(f"{destination.stem}.metadata-copy{destination.suffix}")
|
||||
temp_output = unique_path(temp_output)
|
||||
command = [
|
||||
ffmpeg,
|
||||
"-y",
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
str(destination),
|
||||
"-i",
|
||||
str(source),
|
||||
"-map",
|
||||
"0",
|
||||
"-map_metadata",
|
||||
"1",
|
||||
"-c",
|
||||
"copy",
|
||||
str(temp_output),
|
||||
]
|
||||
try:
|
||||
subprocess.run(command, check=True, capture_output=True, text=True)
|
||||
temp_output.replace(destination)
|
||||
finally:
|
||||
if temp_output.exists():
|
||||
temp_output.unlink()
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Preview and apply a prepared photo metadata plan."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
|
||||
from tools.console import ProgressView
|
||||
from tools.exiftool import run_exiftool_write
|
||||
from tools.filesystem import display_path_from_working_dir, unique_existing_target
|
||||
from tools.photo_planning import PtoPlan, UserChoices, build_write_args
|
||||
from tools.photo_records import MediaRecord
|
||||
from tools.timezones import timezone_to_string
|
||||
|
||||
|
||||
EXIFTOOL_WRITE_WORKERS = 4
|
||||
METADATA_WORK_OVERHEAD = 512 * 1024
|
||||
FILE_OPERATION_WORK = 16 * 1024
|
||||
|
||||
|
||||
def _time_offset_text(value) -> str:
|
||||
seconds = int(value.total_seconds())
|
||||
sign, seconds = ("+" if seconds >= 0 else "-"), abs(seconds)
|
||||
return f"{sign}{seconds // 3600:02d}:{seconds % 3600 // 60:02d}:{seconds % 60:02d}"
|
||||
|
||||
|
||||
def _display(path: Path, choices: UserChoices, relative: bool) -> str:
|
||||
return display_path_from_working_dir(path, choices.working_dir) if relative else str(path)
|
||||
|
||||
|
||||
def move_rename_preview(source: Path, target: Path, choices: UserChoices, label: str | None = None) -> str | None:
|
||||
moving, renaming = source.parent.resolve() != target.parent.resolve(), source.name != target.name
|
||||
source_text = label or _display(source, choices, moving)
|
||||
if moving and renaming:
|
||||
return f"move+rename {source_text} -> {_display(target, choices, True)}"
|
||||
if moving:
|
||||
destination = _display(target.parent, choices, True)
|
||||
return f"move {source_text} -> {destination if destination == '.' or destination.endswith(os.sep) else destination + os.sep}"
|
||||
return f"rename {label or source.name} -> {target.name}" if renaming else None
|
||||
|
||||
|
||||
def print_preview(records: list[MediaRecord], choices: UserChoices, pto_plans: list[PtoPlan]) -> None:
|
||||
print("\nPreview\n\nCommon changes")
|
||||
print(f" Working directory: {choices.working_dir}")
|
||||
print(f" Time correction: {_time_offset_text(choices.time_offset) if choices.time_offset else 'none'}")
|
||||
if choices.fixed_timezone:
|
||||
print(f" Timezone offset: {timezone_to_string(choices.fixed_timezone)}")
|
||||
elif choices.fill_timezone_gaps:
|
||||
print(" Timezone offsets: fill missing gaps")
|
||||
print(f" Filename mode: {choices.rename_mode.replace('_', '/')}")
|
||||
print(f" Organize/group: {'yes' if choices.organize_files else 'no'}")
|
||||
if choices.organize_files:
|
||||
print(f" Burst/HDR minimum: {choices.group_min_size}+ files")
|
||||
print(f" Panorama project files: {'yes' if choices.process_pto_files else 'no'}")
|
||||
if choices.artist_action != "leave":
|
||||
print(f" Artist/author: {'clear' if choices.artist_action == 'clear' else 'set'}")
|
||||
inferred = [record for record in records if record.inferred_timestamp_source]
|
||||
if inferred:
|
||||
print("\nInferred timestamps")
|
||||
for record in inferred:
|
||||
print(f" {record.path.name}: {record.adjusted_time} from {record.inferred_timestamp_source}")
|
||||
warnings = [(record.path.name, warning) for record in records for warning in record.warnings]
|
||||
if warnings:
|
||||
print("\nWarnings")
|
||||
for name, warning in warnings:
|
||||
print(f" {name}: {warning}")
|
||||
print("\nFile operations")
|
||||
for record in sorted(records, key=lambda item: str(item.path).lower()):
|
||||
operation = move_rename_preview(record.path, record.target_path or record.path, choices)
|
||||
if operation:
|
||||
print(operation)
|
||||
if record.rename_skip_reason:
|
||||
print(f" {record.path.name}: rename skipped: {record.rename_skip_reason}")
|
||||
if record.target_sidecar and record.sidecar:
|
||||
print(f" sidecar move {_display(record.sidecar.path, choices, True)} -> {_display(record.target_sidecar, choices, True)}")
|
||||
if pto_plans:
|
||||
print("\nPTO updates:")
|
||||
for plan in pto_plans:
|
||||
operation = move_rename_preview(plan.path, plan.target_path, choices, _display(plan.path, choices, True))
|
||||
print(operation or _display(plan.path, choices, True))
|
||||
for old, new in plan.replacements:
|
||||
print(f" {old} -> {new}")
|
||||
|
||||
|
||||
def _operation(record: MediaRecord) -> str:
|
||||
target = record.target_path or record.path
|
||||
if target.parent.resolve() != record.path.parent.resolve():
|
||||
return "move+rename" if target.name != record.path.name else "move"
|
||||
return "rename" if target.name != record.path.name else "no-op"
|
||||
|
||||
|
||||
def _work(record: MediaRecord, args: list[str]) -> float:
|
||||
if not args:
|
||||
return FILE_OPERATION_WORK
|
||||
try:
|
||||
return FILE_OPERATION_WORK + METADATA_WORK_OVERHEAD + record.path.stat().st_size
|
||||
except OSError:
|
||||
return FILE_OPERATION_WORK + METADATA_WORK_OVERHEAD
|
||||
|
||||
|
||||
def _move_record(record: MediaRecord, planned_sources: set[Path]) -> None:
|
||||
if record.target_path and record.target_path != record.path:
|
||||
target = unique_existing_target(record.target_path, planned_sources)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(record.path), str(target))
|
||||
record.target_path = target
|
||||
if record.sidecar and record.target_sidecar and record.sidecar.path.exists():
|
||||
target = unique_existing_target(record.target_sidecar, planned_sources)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(record.sidecar.path), str(target))
|
||||
|
||||
|
||||
def apply_changes(executable: str, records: list[MediaRecord], choices: UserChoices) -> list[str]:
|
||||
failures, sources = [], {record.path.resolve() for record in records}
|
||||
args = {record.path: build_write_args(record, choices) for record in records}
|
||||
work = {record.path: _work(record, args[record.path]) for record in records}
|
||||
progress = ProgressView(len(records), "Progress", total_work=sum(work.values()), show_elapsed=False, bar_width=16)
|
||||
counts = {key: 0 for key in ("succeeded", "failed", "metadata", "rename", "move", "move+rename", "no-op")}
|
||||
completed = 0.0
|
||||
with ThreadPoolExecutor(max_workers=EXIFTOOL_WRITE_WORKERS) as workers:
|
||||
futures: dict[Path, Future[None]] = {record.path: workers.submit(run_exiftool_write, executable, record.path, write_args) for record in records if (write_args := args[record.path])}
|
||||
for index, record in enumerate(records, 1):
|
||||
operation = _operation(record)
|
||||
try:
|
||||
if args[record.path]:
|
||||
futures[record.path].result()
|
||||
counts["metadata"] += 1
|
||||
_move_record(record, sources)
|
||||
counts["succeeded"] += 1
|
||||
counts[operation] += 1 if operation != "no-op" or not args[record.path] else 0
|
||||
except (OSError, subprocess.CalledProcessError, RuntimeError) as exc:
|
||||
counts["failed"] += 1
|
||||
failures.append(f"{record.path}: {exc}")
|
||||
completed += work[record.path]
|
||||
progress.write_lines([
|
||||
"Applying changes",
|
||||
f" {progress.update_line(index, completed_work=completed)}",
|
||||
f" Results: succeeded {counts['succeeded']} failed {counts['failed']} metadata {counts['metadata']} no-op {counts['no-op']}",
|
||||
f" File ops: rename {counts['rename']} move {counts['move']} rename+move {counts['move+rename']}",
|
||||
])
|
||||
progress.finish(keep=True)
|
||||
return failures
|
||||
|
||||
|
||||
def apply_pto_changes(plans: list[PtoPlan]) -> list[str]:
|
||||
failures = []
|
||||
for plan in plans:
|
||||
try:
|
||||
plan.path.write_text(plan.updated_text, encoding="utf-8")
|
||||
if plan.target_path.resolve() != plan.path.resolve():
|
||||
target = unique_existing_target(plan.target_path, {plan.path.resolve()})
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(plan.path), str(target))
|
||||
except OSError as exc:
|
||||
failures.append(f"{plan.path}: {exc}")
|
||||
return failures
|
||||
|
||||
|
||||
def print_removed_dirs(directories: list[Path]) -> None:
|
||||
if directories:
|
||||
print("\nRemoved empty director" + ("y:" if len(directories) == 1 else "ies:"))
|
||||
for directory in directories:
|
||||
print(f" - {directory}")
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Burst/HDR group detection for photo metadata cleanup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from tools.filenames import naive_wall_time
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from photo_metadata import MediaRecord
|
||||
|
||||
|
||||
def photo_sort_key(record: MediaRecord) -> tuple:
|
||||
assert record.adjusted_time is not None
|
||||
return (
|
||||
naive_wall_time(record.adjusted_time),
|
||||
record.subsec if record.subsec is not None else -1,
|
||||
record.sequence if record.sequence is not None else -1,
|
||||
record.path.name.lower(),
|
||||
)
|
||||
|
||||
|
||||
def group_sort_key(record: MediaRecord) -> tuple:
|
||||
timestamp = naive_wall_time(record.adjusted_time) if record.adjusted_time else datetime.max
|
||||
return (
|
||||
timestamp,
|
||||
record.subsec if record.subsec is not None else -1,
|
||||
record.sequence if record.sequence is not None else -1,
|
||||
str(record.path).lower(),
|
||||
)
|
||||
|
||||
|
||||
def detect_photo_groups(records: list[MediaRecord], min_size: int = 2) -> list[list[MediaRecord]]:
|
||||
by_camera: dict[str, list[MediaRecord]] = defaultdict(list)
|
||||
for record in records:
|
||||
if record.is_image and record.adjusted_time is not None:
|
||||
by_camera[record.camera_key].append(record)
|
||||
return [
|
||||
group
|
||||
for camera_records in by_camera.values()
|
||||
for group in detect_groups_for_camera(sorted(camera_records, key=photo_sort_key), min_size)
|
||||
]
|
||||
|
||||
|
||||
def detect_groups_for_camera(records: list[MediaRecord], min_size: int) -> list[list[MediaRecord]]:
|
||||
by_second: dict[datetime, list[MediaRecord]] = defaultdict(list)
|
||||
for record in records:
|
||||
assert record.adjusted_time is not None
|
||||
by_second[naive_wall_time(record.adjusted_time).replace(microsecond=0)].append(record)
|
||||
|
||||
seconds = sorted(by_second)
|
||||
spans: list[list[datetime]] = []
|
||||
for second in seconds:
|
||||
if not spans or second != spans[-1][-1] + timedelta(seconds=1):
|
||||
spans.append([])
|
||||
spans[-1].append(second)
|
||||
|
||||
groups: list[list[MediaRecord]] = []
|
||||
for span in spans:
|
||||
if max(len(by_second[second]) for second in span) < 2:
|
||||
continue
|
||||
span_records = [record for second in span for record in by_second[second]]
|
||||
if len(span_records) >= 2 and all(record.sequence is not None for record in span_records):
|
||||
groups.extend(sequence_groups(span_records, min_size))
|
||||
else:
|
||||
groups.extend(timestamp_groups(span, by_second, min_size))
|
||||
return [group for group in groups if len(group) >= min_size]
|
||||
|
||||
|
||||
def sequence_groups(records: list[MediaRecord], min_size: int) -> list[list[MediaRecord]]:
|
||||
groups: list[list[MediaRecord]] = []
|
||||
for record in sorted(records, key=photo_sort_key):
|
||||
if not groups or record.sequence <= groups[-1][-1].sequence:
|
||||
groups.append([])
|
||||
groups[-1].append(record)
|
||||
return [group for group in groups if len(group) >= min_size]
|
||||
|
||||
|
||||
def timestamp_groups(
|
||||
span: list[datetime], by_second: dict[datetime, list[MediaRecord]], min_size: int
|
||||
) -> list[list[MediaRecord]]:
|
||||
multi_indices = [index for index, second in enumerate(span) if len(by_second[second]) >= 2]
|
||||
if not multi_indices:
|
||||
return []
|
||||
|
||||
clusters: list[list[int]] = [[multi_indices[0]]]
|
||||
for index in multi_indices[1:]:
|
||||
if index - clusters[-1][-1] > 2:
|
||||
clusters.append([])
|
||||
clusters[-1].append(index)
|
||||
|
||||
groups: list[list[MediaRecord]] = []
|
||||
used_seconds: set[datetime] = set()
|
||||
for cluster in clusters:
|
||||
start, end = cluster[0], cluster[-1]
|
||||
if start and len(by_second[span[start - 1]]) == 1:
|
||||
start -= 1
|
||||
if end + 1 < len(span) and len(by_second[span[end + 1]]) == 1:
|
||||
end += 1
|
||||
seconds = [second for second in span[start : end + 1] if second not in used_seconds]
|
||||
used_seconds.update(seconds)
|
||||
records = [record for second in seconds for record in sorted(by_second[second], key=photo_sort_key)]
|
||||
if len(records) >= min_size:
|
||||
groups.append(records)
|
||||
return groups
|
||||
|
||||
|
||||
def infer_group_subseconds(groups: list[list[MediaRecord]]) -> None:
|
||||
for group in groups:
|
||||
if any(record.subsec is not None for record in group):
|
||||
continue
|
||||
by_second: dict[datetime, list[MediaRecord]] = defaultdict(list)
|
||||
for record in group:
|
||||
if record.is_image and record.adjusted_time is not None:
|
||||
by_second[naive_wall_time(record.adjusted_time).replace(microsecond=0)].append(record)
|
||||
|
||||
seconds = sorted(by_second)
|
||||
counts = {second: len(records) for second, records in by_second.items()}
|
||||
for index, second in enumerate(seconds):
|
||||
records = sorted(by_second[second], key=photo_sort_key)
|
||||
count = len(records)
|
||||
if count <= 1:
|
||||
continue
|
||||
if len(seconds) == 2:
|
||||
fps = max(counts.values())
|
||||
elif index == 0:
|
||||
fps = max(count, counts[seconds[1]])
|
||||
elif index == len(seconds) - 1:
|
||||
fps = max(count, counts[seconds[-2]])
|
||||
else:
|
||||
fps = count
|
||||
start_slot = fps - count if index == 0 and fps > count else 0
|
||||
for item_index, record in enumerate(records):
|
||||
record.inferred_subsec = int((start_slot + item_index) * 1000 / fps)
|
||||
@@ -0,0 +1,383 @@
|
||||
"""Pure planning for photo metadata updates, grouping, and PTO projects."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from tools.filenames import format_filename_stem, group_stem_from_name, naive_wall_time, normalized_extension
|
||||
from tools.media_metadata import first_tag, parse_exif_datetime, round_half_up, tag_value
|
||||
from tools.photo_grouping import detect_photo_groups, group_sort_key, infer_group_subseconds
|
||||
from tools.photo_records import FILENAME_TIMESTAMP_RE, IMAGE_MODIFY_TIME_TAGS, MediaRecord, TagWrite, parse_filename_timestamp, parse_subsec
|
||||
from tools.timezones import timezone_to_string
|
||||
|
||||
|
||||
IMAGE_WRITE_TIME_TAGS = (
|
||||
"EXIF:DateTimeOriginal", "EXIF:CreateDate", "XMP:DateTimeOriginal", "XMP:CreationDate",
|
||||
"XMP:CreateDate", "EXIF:ModifyDate", "XMP:ModifyDate",
|
||||
)
|
||||
OFFSET_TAG_FOR_TIME_TAG = {
|
||||
"EXIF:DateTimeOriginal": "EXIF:OffsetTimeOriginal",
|
||||
"EXIF:CreateDate": "EXIF:OffsetTimeDigitized",
|
||||
"EXIF:ModifyDate": "EXIF:OffsetTime",
|
||||
}
|
||||
CAMERA_RANGE_STEM_RE = re.compile(r"^(?P<prefix>[A-Za-z]*)(?P<first>\d+)-(?P=prefix)(?P<last>\d+)(?P<suffix>_.*)$")
|
||||
PTO_QUOTED_VALUE_RE = re.compile(r"([\"'])(.*?)(\1)")
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserChoices:
|
||||
working_dir: Path
|
||||
time_offset: timedelta | None
|
||||
fixed_timezone: timezone | None
|
||||
fill_timezone_gaps: bool
|
||||
artist_action: str
|
||||
artist_value: str | None
|
||||
rename_mode: str
|
||||
organize_files: bool
|
||||
group_min_size: int
|
||||
process_pto_files: bool
|
||||
infer_missing_timestamps: bool
|
||||
|
||||
@property
|
||||
def group_photos(self) -> bool:
|
||||
return self.organize_files and self.group_min_size >= 2
|
||||
|
||||
@property
|
||||
def move_to_working_dir(self) -> bool:
|
||||
return self.organize_files
|
||||
|
||||
|
||||
@dataclass
|
||||
class PtoPlan:
|
||||
path: Path
|
||||
target_path: Path
|
||||
referenced_records: list[MediaRecord]
|
||||
updated_text: str
|
||||
replacements: list[tuple[str, str]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PtoReference:
|
||||
path: Path
|
||||
original_text: str
|
||||
records: list[MediaRecord]
|
||||
|
||||
|
||||
def apply_time_offset(records: list[MediaRecord], offset: timedelta | None) -> None:
|
||||
if offset is not None:
|
||||
for record in records:
|
||||
if record.adjusted_time is not None:
|
||||
record.adjusted_time += offset
|
||||
|
||||
|
||||
def infer_missing_timestamps(records: list[MediaRecord], enabled: bool) -> None:
|
||||
if not enabled:
|
||||
return
|
||||
for record in records:
|
||||
if record.original_time is not None:
|
||||
continue
|
||||
parsed, source = parse_filename_timestamp(record), "filename"
|
||||
if parsed is None and record.is_image:
|
||||
parsed, source = parse_exif_datetime(first_tag(record.metadata, IMAGE_MODIFY_TIME_TAGS)), "ModifyDate"
|
||||
if parsed is None:
|
||||
continue
|
||||
if record.is_video:
|
||||
if record.resolved_timezone is None or record.duration_seconds is None:
|
||||
continue
|
||||
parsed = parsed.replace(tzinfo=record.resolved_timezone).astimezone(timezone.utc)
|
||||
parsed += timedelta(seconds=record.duration_seconds)
|
||||
record.original_time = record.adjusted_time = parsed
|
||||
record.inferred_timestamp_source = source
|
||||
record.parsed_from_filename = source == "filename"
|
||||
|
||||
|
||||
def filename_timestamp_stem(stem: str, timestamp: datetime, mode: str) -> str:
|
||||
matches = [(match, parse_exif_datetime(match.group(0))) for match in FILENAME_TIMESTAMP_RE.finditer(stem)]
|
||||
matches = [(match, value) for match, value in matches if value is not None]
|
||||
if mode.startswith("adjust") and matches:
|
||||
first_time = matches[0][1]
|
||||
pieces, position, base = [], 0, naive_wall_time(timestamp)
|
||||
for match, old_time in matches:
|
||||
replacement = format_filename_stem(base + (old_time - first_time)).replace("_", match.group(0)[8])
|
||||
pieces.extend((stem[position:match.start()], replacement))
|
||||
position = match.end()
|
||||
return "".join((*pieces, stem[position:]))
|
||||
formatted = format_filename_stem(timestamp)
|
||||
return f"{formatted} {stem}" if mode.endswith("add") else formatted
|
||||
|
||||
|
||||
def plan_names(records: list[MediaRecord], choices: UserChoices) -> None:
|
||||
by_stem = _unique_records_by_stem(records)
|
||||
for record in records:
|
||||
record.new_stem = record.rename_skip_reason = None
|
||||
if record.adjusted_time is None:
|
||||
record.new_stem = _camera_range_stem(record, by_stem)
|
||||
record.rename_skip_reason = None if record.new_stem else "missing timestamp"
|
||||
elif record.is_video:
|
||||
if record.duration_seconds is None:
|
||||
record.rename_skip_reason = "missing duration"
|
||||
elif record.resolved_timezone is None:
|
||||
record.rename_skip_reason = "missing video timezone"
|
||||
else:
|
||||
begin = record.adjusted_time if record.video_time_is_beginning else record.adjusted_time - timedelta(seconds=round_half_up(record.duration_seconds))
|
||||
if begin.tzinfo is None:
|
||||
begin = begin.replace(tzinfo=timezone.utc)
|
||||
record.new_stem = filename_timestamp_stem(record.path.stem, begin.astimezone(record.resolved_timezone), choices.rename_mode)
|
||||
else:
|
||||
record.new_stem = filename_timestamp_stem(record.path.stem, record.adjusted_time, choices.rename_mode)
|
||||
_assign_unique_names(records)
|
||||
|
||||
|
||||
def _unique_records_by_stem(records: list[MediaRecord]) -> dict[str, MediaRecord]:
|
||||
buckets: dict[str, list[MediaRecord]] = defaultdict(list)
|
||||
for record in records:
|
||||
buckets[record.path.stem.lower()].append(record)
|
||||
return {stem: bucket[0] for stem, bucket in buckets.items() if len(bucket) == 1}
|
||||
|
||||
|
||||
def _camera_range_stem(record: MediaRecord, by_stem: dict[str, MediaRecord]) -> str | None:
|
||||
match = CAMERA_RANGE_STEM_RE.match(record.path.stem)
|
||||
if not match or int(match.group("last")) <= int(match.group("first")):
|
||||
return None
|
||||
prefix = match.group("prefix")
|
||||
first = by_stem.get(f"{prefix}{match.group('first')}".lower())
|
||||
last = by_stem.get(f"{prefix}{match.group('last')}".lower())
|
||||
start = record.adjusted_time or (first.adjusted_time if first else None) or (last.adjusted_time if last else None)
|
||||
end = (last.adjusted_time if last else None) or (first.adjusted_time if first else None) or start
|
||||
return f"{format_filename_stem(start)}-{format_filename_stem(end)}{match.group('suffix')}" if start and end else None
|
||||
|
||||
|
||||
def _assign_unique_names(records: list[MediaRecord]) -> None:
|
||||
buckets: dict[tuple[int | str, str], list[MediaRecord]] = defaultdict(list)
|
||||
for record in records:
|
||||
if record.new_stem:
|
||||
buckets[(record.group_id if record.group_id is not None else str(record.path.parent.resolve()), record.new_stem.lower())].append(record)
|
||||
for bucket in buckets.values():
|
||||
if len(bucket) == 1:
|
||||
bucket[0].final_name = f"{bucket[0].new_stem}{normalized_extension(bucket[0].path)}"
|
||||
continue
|
||||
for index, record in enumerate(sorted(bucket, key=group_sort_key), 1):
|
||||
record.final_name = f"{record.new_stem}-{index}{normalized_extension(record.path)}"
|
||||
|
||||
|
||||
def merge_record_groups(records: list[MediaRecord], candidates: list[list[MediaRecord]]) -> list[list[MediaRecord]]:
|
||||
indexes = {record.path.resolve(): index for index, record in enumerate(records)}
|
||||
parents = list(range(len(records)))
|
||||
grouped: set[int] = set()
|
||||
|
||||
def find(index: int) -> int:
|
||||
while parents[index] != index:
|
||||
parents[index] = parents[parents[index]]
|
||||
index = parents[index]
|
||||
return index
|
||||
|
||||
for candidate in candidates:
|
||||
members = [indexes[record.path.resolve()] for record in candidate if record.path.resolve() in indexes]
|
||||
if members:
|
||||
grouped.update(members)
|
||||
for index in members[1:]:
|
||||
parents[find(index)] = find(members[0])
|
||||
merged: dict[int, list[MediaRecord]] = defaultdict(list)
|
||||
for index in grouped:
|
||||
merged[find(index)].append(records[index])
|
||||
return sorted((sorted(group, key=group_sort_key) for group in merged.values()), key=lambda group: group_sort_key(group[0]))
|
||||
|
||||
|
||||
def assign_groups(records: list[MediaRecord], groups: list[list[MediaRecord]]) -> None:
|
||||
for record in records:
|
||||
record.group_id = record.group_name = None
|
||||
for index, group in enumerate(groups, 1):
|
||||
for record in group:
|
||||
record.group_id = index
|
||||
|
||||
|
||||
def _group_name(base: str, working_dir: Path, used: set[str]) -> str:
|
||||
if (working_dir / base).exists() or base not in used:
|
||||
used.add(base)
|
||||
return base
|
||||
for index in range(1, 10000):
|
||||
name = f"{base}-{index}"
|
||||
if name not in used and not (working_dir / name).exists():
|
||||
used.add(name)
|
||||
return name
|
||||
raise RuntimeError(f"Could not find an available group directory name for {base}")
|
||||
|
||||
|
||||
def assign_group_names(groups: list[list[MediaRecord]], working_dir: Path) -> None:
|
||||
used: set[str] = set()
|
||||
for group in groups:
|
||||
ordered = sorted(group, key=group_sort_key)
|
||||
first, last = (group_stem_from_name(record.final_name or record.path.name) for record in (ordered[0], ordered[-1]))
|
||||
name = _group_name(f"{first}-{last}", working_dir, used)
|
||||
for record in group:
|
||||
record.group_name = name
|
||||
|
||||
|
||||
def plan_targets(records: list[MediaRecord], choices: UserChoices) -> None:
|
||||
for record in records:
|
||||
directory = choices.working_dir if choices.organize_files else record.path.parent
|
||||
if record.group_name:
|
||||
directory /= record.group_name
|
||||
name = record.final_name or record.path.name
|
||||
record.target_path = directory / name
|
||||
if record.sidecar:
|
||||
record.target_sidecar = directory / f"{Path(name).stem}M01.XML"
|
||||
|
||||
|
||||
def normalize_reference(value: str) -> str:
|
||||
return value.replace("\\", "/")
|
||||
|
||||
|
||||
def _pto_mapping(pto_path: Path, records: list[MediaRecord]) -> dict[str, MediaRecord]:
|
||||
basename_counts: dict[str, int] = defaultdict(int)
|
||||
for record in records:
|
||||
basename_counts[record.path.name] += 1
|
||||
mapping: dict[str, MediaRecord] = {}
|
||||
for record in records:
|
||||
candidates = {normalize_reference(str(record.path.resolve())), normalize_reference(os.path.relpath(record.path.resolve(), pto_path.parent))}
|
||||
if basename_counts[record.path.name] == 1:
|
||||
candidates.add(record.path.name)
|
||||
for candidate in candidates:
|
||||
mapping[normalize_reference(candidate)] = record
|
||||
return mapping
|
||||
|
||||
|
||||
def _pto_target(path: Path, directory: Path, base: str, used: set[Path]) -> Path:
|
||||
for index in range(10000):
|
||||
target = directory / f"{base}{'' if index == 0 else f'-{index}'}.pto"
|
||||
if target.resolve() == path.resolve() or (not target.exists() and target not in used):
|
||||
used.add(target)
|
||||
return target
|
||||
raise RuntimeError(f"Could not find an available .pto name for {base}")
|
||||
|
||||
|
||||
def read_pto_references(pto_files: list[Path], records: list[MediaRecord]) -> list[PtoReference]:
|
||||
references = []
|
||||
for path in pto_files:
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
text = path.read_text(encoding="utf-8", errors="replace")
|
||||
mapping = _pto_mapping(path, records)
|
||||
found = []
|
||||
for match in PTO_QUOTED_VALUE_RE.finditer(text):
|
||||
record = mapping.get(normalize_reference(match.group(2)))
|
||||
if record is not None and record not in found:
|
||||
found.append(record)
|
||||
if found:
|
||||
references.append(PtoReference(path, text, found))
|
||||
return references
|
||||
|
||||
|
||||
def plan_pto_updates(references: list[PtoReference], records: list[MediaRecord]) -> list[PtoPlan]:
|
||||
plans, used = [], set()
|
||||
for reference in references:
|
||||
referenced = sorted(reference.records, key=group_sort_key)
|
||||
target_dir = referenced[0].target_path.parent if referenced[0].group_name and referenced[0].target_path else reference.path.parent
|
||||
mapping, replacements = _pto_mapping(reference.path, records), []
|
||||
|
||||
def replace(match: re.Match) -> str:
|
||||
value = match.group(2)
|
||||
record = mapping.get(normalize_reference(value))
|
||||
if record is None or record.target_path is None:
|
||||
return match.group(0)
|
||||
replacement = normalize_reference(os.path.relpath(record.target_path.resolve(), target_dir))
|
||||
if value != replacement:
|
||||
replacements.append((value, replacement))
|
||||
return f"{match.group(1)}{replacement}{match.group(1)}"
|
||||
|
||||
text = PTO_QUOTED_VALUE_RE.sub(replace, reference.original_text)
|
||||
base = f"{group_stem_from_name(referenced[0].target_path.name)}-{group_stem_from_name(referenced[-1].target_path.name)}"
|
||||
target = _pto_target(reference.path, target_dir, base, used)
|
||||
if text != reference.original_text or target.resolve() != reference.path.resolve():
|
||||
plans.append(PtoPlan(reference.path, target, referenced, text, replacements))
|
||||
return plans
|
||||
|
||||
|
||||
def _same_text(current: str | None, value: str) -> bool:
|
||||
return (current or "") == value
|
||||
|
||||
|
||||
def _same_datetime(current: str | None, value: str) -> bool:
|
||||
current_time, new_time = parse_exif_datetime(current), parse_exif_datetime(value)
|
||||
return current == value if current_time is None or new_time is None else naive_wall_time(current_time) == naive_wall_time(new_time)
|
||||
|
||||
|
||||
def _same_subsec(current: str | None, value: str) -> bool:
|
||||
parsed = parse_subsec(current)
|
||||
return current == value if parsed is None else f"{parsed:03d}" == value
|
||||
|
||||
|
||||
def _add_write(writes: list[TagWrite], label: str, tag: str, current: str | None, value: str, same=_same_text) -> None:
|
||||
writes.append(TagWrite(label, f"-{tag}={value}", current, value, not same(current, value)))
|
||||
|
||||
|
||||
def build_write_plan(record: MediaRecord, choices: UserChoices) -> list[TagWrite]:
|
||||
writes: list[TagWrite] = []
|
||||
if choices.artist_action != "leave":
|
||||
value = choices.artist_value or ""
|
||||
for label, tag in (("Artist", "Artist"), ("Author", "Author")):
|
||||
_add_write(writes, label, tag, tag_value(record.metadata, f"EXIF:{tag}") or tag_value(record.metadata, f"XMP:{tag}"), value)
|
||||
if record.is_image:
|
||||
capture_tags: set[str] = set()
|
||||
if record.inferred_timestamp_source and record.adjusted_time:
|
||||
capture_tags.add("EXIF:DateTimeOriginal")
|
||||
_add_write(writes, "DateTimeOriginal", "EXIF:DateTimeOriginal", tag_value(record.metadata, "EXIF:DateTimeOriginal"), record.adjusted_time.strftime("%Y:%m:%d %H:%M:%S"), _same_datetime)
|
||||
if record.original_time and record.adjusted_time:
|
||||
original, adjusted = naive_wall_time(record.original_time), naive_wall_time(record.adjusted_time)
|
||||
for tag in IMAGE_WRITE_TIME_TAGS:
|
||||
current = parse_exif_datetime(tag_value(record.metadata, tag))
|
||||
if current is None:
|
||||
continue
|
||||
current_time = naive_wall_time(current)
|
||||
matches_capture = abs((current_time - original).total_seconds()) <= 1
|
||||
if matches_capture:
|
||||
capture_tags.add(tag)
|
||||
target = current_time + choices.time_offset if choices.time_offset and matches_capture else current_time
|
||||
if tag.endswith("ModifyDate") and target < adjusted:
|
||||
target, capture_tags = adjusted, capture_tags | {tag}
|
||||
if "ModifyDate was earlier than capture time and will be clamped" not in record.warnings:
|
||||
record.warnings.append("ModifyDate was earlier than capture time and will be clamped")
|
||||
if target != current_time:
|
||||
_add_write(writes, tag.split(":", 1)[1], tag, tag_value(record.metadata, tag), target.strftime("%Y:%m:%d %H:%M:%S"), _same_datetime)
|
||||
if record.resolved_timezone and (choices.fixed_timezone or record.timezone_value is None):
|
||||
for time_tag, offset_tag in OFFSET_TAG_FOR_TIME_TAG.items():
|
||||
if time_tag in capture_tags:
|
||||
_add_write(writes, offset_tag.split(":", 1)[1], offset_tag, tag_value(record.metadata, offset_tag), timezone_to_string(record.resolved_timezone))
|
||||
if record.inferred_subsec is not None:
|
||||
for label, tag in (("SubSecTimeOriginal", "EXIF:SubSecTimeOriginal"), ("SubSecTimeDigitized", "EXIF:SubSecTimeDigitized"), ("SubSecTime", "EXIF:SubSecTime")):
|
||||
_add_write(writes, label, tag, tag_value(record.metadata, tag), f"{record.inferred_subsec:03d}", _same_subsec)
|
||||
elif record.is_video and (choices.time_offset or record.inferred_timestamp_source) and record.adjusted_time:
|
||||
value = record.adjusted_time.replace(tzinfo=record.adjusted_time.tzinfo or timezone.utc).astimezone(timezone.utc).strftime("%Y:%m:%d %H:%M:%S")
|
||||
for tag in ("QuickTime:CreateDate", "QuickTime:ModifyDate", "QuickTime:TrackCreateDate", "QuickTime:TrackModifyDate", "QuickTime:MediaCreateDate", "QuickTime:MediaModifyDate"):
|
||||
_add_write(writes, tag.split(":", 1)[1], tag, tag_value(record.metadata, tag), value, _same_datetime)
|
||||
return writes
|
||||
|
||||
|
||||
def build_write_args(record: MediaRecord, choices: UserChoices) -> list[str]:
|
||||
if not record.write_plan:
|
||||
record.write_plan = build_write_plan(record, choices)
|
||||
return [write.arg for write in record.write_plan if write.will_write]
|
||||
|
||||
|
||||
def prepare_plan(records: list[MediaRecord], choices: UserChoices, references: list[PtoReference]) -> list[list[MediaRecord]]:
|
||||
infer_missing_timestamps(records, choices.infer_missing_timestamps)
|
||||
apply_time_offset(records, choices.time_offset)
|
||||
burst_groups = detect_photo_groups(records, choices.group_min_size) if choices.group_photos else []
|
||||
groups = merge_record_groups(records, [*burst_groups, *(reference.records for reference in references if choices.process_pto_files)])
|
||||
if groups:
|
||||
assign_groups(records, groups)
|
||||
if choices.organize_files:
|
||||
infer_group_subseconds(burst_groups)
|
||||
plan_names(records, choices)
|
||||
if groups:
|
||||
assign_group_names(groups, choices.working_dir)
|
||||
plan_targets(records, choices)
|
||||
for record in records:
|
||||
record.write_plan = build_write_plan(record, choices)
|
||||
return groups
|
||||
@@ -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
|
||||
@@ -0,0 +1,543 @@
|
||||
"""Raw-key settings UI for photo metadata cleanup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from tools.console import dark_field, dim, draw_screen, inverse, light_red, raw_key_input
|
||||
from tools.exiftool import run_exiftool_json
|
||||
from tools.filenames import naive_wall_time
|
||||
from tools.media_metadata import DATETIME_RE
|
||||
from tools.photo_planning import UserChoices
|
||||
from tools.photo_records import IMAGE_EXTS, MediaRecord, build_records
|
||||
from tools.timezones import local_timezone, parse_timezone_offset, timezone_to_string
|
||||
|
||||
TIME_ONLY_RE = re.compile(r"^(?P<h>\d{1,2})(?::?(?P<m>\d{2}))(?::?(?P<s>\d{2}))?$")
|
||||
TOKEN_SHIFT_RE = re.compile(r"(\d+|[a-zA-Z]+)")
|
||||
|
||||
def parse_datetime(value: str, default_date: datetime | None = None) -> datetime:
|
||||
text = value.strip()
|
||||
match = DATETIME_RE.search(text)
|
||||
if match:
|
||||
tzinfo = None
|
||||
tz_text = match.group("tz")
|
||||
if tz_text == "Z":
|
||||
tzinfo = timezone.utc
|
||||
elif tz_text:
|
||||
tzinfo = parse_timezone_offset(tz_text)
|
||||
return datetime(
|
||||
int(match.group("Y")),
|
||||
int(match.group("M")),
|
||||
int(match.group("D")),
|
||||
int(match.group("h")),
|
||||
int(match.group("m")),
|
||||
int(match.group("s")),
|
||||
tzinfo=tzinfo,
|
||||
)
|
||||
|
||||
match = TIME_ONLY_RE.match(text)
|
||||
if match and default_date is not None:
|
||||
return default_date.replace(
|
||||
hour=int(match.group("h")),
|
||||
minute=int(match.group("m")),
|
||||
second=int(match.group("s") or "0"),
|
||||
microsecond=0,
|
||||
)
|
||||
|
||||
raise ValueError("timestamp must include YYYYMMDD_HHMMSS or a time with reference date")
|
||||
|
||||
|
||||
def parse_timeshift(value: str) -> timedelta:
|
||||
text = value.strip().replace(" ", "")
|
||||
if not text or text[0] not in "+-":
|
||||
raise ValueError("time shift must start with + or -")
|
||||
sign = 1 if text[0] == "+" else -1
|
||||
body = text[1:]
|
||||
|
||||
if re.fullmatch(r"\d+(?::\d{2}){0,2}", body):
|
||||
parts = [int(part) for part in body.split(":")]
|
||||
parts = [0] * (3 - len(parts)) + parts
|
||||
return sign * timedelta(hours=parts[0], minutes=parts[1], seconds=parts[2])
|
||||
|
||||
units = {
|
||||
"h": 3600,
|
||||
"hr": 3600,
|
||||
"hrs": 3600,
|
||||
"hour": 3600,
|
||||
"hours": 3600,
|
||||
"m": 60,
|
||||
"min": 60,
|
||||
"mins": 60,
|
||||
"minute": 60,
|
||||
"minutes": 60,
|
||||
"s": 1,
|
||||
"sec": 1,
|
||||
"secs": 1,
|
||||
"second": 1,
|
||||
"seconds": 1,
|
||||
}
|
||||
tokens = TOKEN_SHIFT_RE.findall(body)
|
||||
if not tokens or len(tokens) % 2 != 0:
|
||||
raise ValueError("time shift units must look like +1h30m or -2 minutes")
|
||||
seconds = 0
|
||||
for amount, unit in zip(tokens[::2], tokens[1::2]):
|
||||
if not amount.isdigit() or unit.lower() not in units:
|
||||
raise ValueError("invalid time shift unit")
|
||||
seconds += int(amount) * units[unit.lower()]
|
||||
return sign * timedelta(seconds=seconds)
|
||||
|
||||
|
||||
def infer_reference_target(source_time: datetime, target_text: str) -> datetime:
|
||||
try:
|
||||
return naive_wall_time(parse_datetime(target_text))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
base = naive_wall_time(source_time)
|
||||
target_same_date = parse_datetime(target_text, default_date=base)
|
||||
candidates = [
|
||||
target_same_date - timedelta(days=1),
|
||||
target_same_date,
|
||||
target_same_date + timedelta(days=1),
|
||||
]
|
||||
|
||||
def score(candidate: datetime) -> tuple[int, float]:
|
||||
delta_hours = abs((candidate - base).total_seconds()) / 3600
|
||||
same_date_penalty = 0 if candidate.date() == base.date() else 1
|
||||
return (0 if delta_hours <= 12 else 1, delta_hours + same_date_penalty)
|
||||
|
||||
return min(candidates, key=score)
|
||||
|
||||
|
||||
def format_timedelta(value: timedelta) -> str:
|
||||
total_seconds = int(value.total_seconds())
|
||||
sign = "+" if total_seconds >= 0 else "-"
|
||||
total_seconds = abs(total_seconds)
|
||||
hours = total_seconds // 3600
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
seconds = total_seconds % 60
|
||||
return f"{sign}{hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimezoneAnalysis:
|
||||
ordered_zones: list[timezone]
|
||||
gap_count: int
|
||||
conflicting_gap_count: int
|
||||
|
||||
@property
|
||||
def shifts(self) -> int:
|
||||
return max(0, len(self.ordered_zones) - 1)
|
||||
|
||||
|
||||
@dataclass
|
||||
class SettingsState:
|
||||
time_offset: timedelta | None
|
||||
infer_missing_timestamps: bool
|
||||
fixed_timezone: timezone
|
||||
fixed_timezone_enabled: bool
|
||||
fill_timezone_gaps: bool
|
||||
rename_mode: str = "adjust_replace"
|
||||
artist_enabled: bool = False
|
||||
artist_value: str = ""
|
||||
organize_files: bool = False
|
||||
group_min_size: int = 2
|
||||
process_pto_files: bool = True
|
||||
|
||||
|
||||
def timeline_key(record: MediaRecord) -> tuple[datetime, str]:
|
||||
value = record.original_time
|
||||
if value is None:
|
||||
return datetime.max, record.path.name.lower()
|
||||
if value.tzinfo is not None:
|
||||
value = value.astimezone(timezone.utc)
|
||||
return naive_wall_time(value), record.path.name.lower()
|
||||
|
||||
|
||||
def analyze_timezones(records: list[MediaRecord]) -> TimezoneAnalysis:
|
||||
ordered = sorted(records, key=timeline_key)
|
||||
runs: list[timezone] = []
|
||||
for record in ordered:
|
||||
if record.timezone_value is not None and (not runs or record.timezone_value != runs[-1]):
|
||||
runs.append(record.timezone_value)
|
||||
gaps, conflicts = timezone_gaps(ordered)
|
||||
return TimezoneAnalysis(runs, len(gaps), len(conflicts))
|
||||
|
||||
|
||||
def timezone_gaps(records: list[MediaRecord]) -> tuple[list[list[MediaRecord]], list[list[MediaRecord]]]:
|
||||
gaps: list[list[MediaRecord]] = []
|
||||
conflicts: list[list[MediaRecord]] = []
|
||||
index = 0
|
||||
while index < len(records):
|
||||
if records[index].timezone_value is not None:
|
||||
index += 1
|
||||
continue
|
||||
start = index
|
||||
while index < len(records) and records[index].timezone_value is None:
|
||||
index += 1
|
||||
gap = records[start:index]
|
||||
before = records[start - 1].timezone_value if start else None
|
||||
after = records[index].timezone_value if index < len(records) else None
|
||||
if before is not None and after is not None and before != after:
|
||||
conflicts.append(gap)
|
||||
else:
|
||||
gaps.append(gap)
|
||||
return gaps, conflicts
|
||||
|
||||
|
||||
def apply_timezone_settings(records: list[MediaRecord], state: SettingsState) -> None:
|
||||
for record in records:
|
||||
record.resolved_timezone = state.fixed_timezone if state.fixed_timezone_enabled else record.timezone_value
|
||||
if not state.fill_timezone_gaps or state.fixed_timezone_enabled:
|
||||
return
|
||||
ordered = sorted(records, key=timeline_key)
|
||||
for gap in timezone_gaps(ordered)[0]:
|
||||
start = ordered.index(gap[0])
|
||||
end = ordered.index(gap[-1]) + 1
|
||||
before = ordered[start - 1].resolved_timezone if start else None
|
||||
after = ordered[end].resolved_timezone if end < len(ordered) else None
|
||||
zone = before or after
|
||||
if zone is not None:
|
||||
for record in gap:
|
||||
record.resolved_timezone = zone
|
||||
|
||||
|
||||
def parse_clock_time(value: str, reference: datetime) -> datetime:
|
||||
match = re.fullmatch(r"(\d{1,2}):(\d{1,2}):(\d{1,2})", value.strip())
|
||||
if match is None:
|
||||
raise ValueError("Time must be h:mm:ss.")
|
||||
hour, minute, second = (int(part) for part in match.groups())
|
||||
if hour > 23 or minute > 59 or second > 59:
|
||||
raise ValueError("Time is outside the 24-hour range.")
|
||||
return infer_reference_target(reference, f"{hour:02d}:{minute:02d}:{second:02d}")
|
||||
|
||||
|
||||
def reference_record(executable: str, records: list[MediaRecord], value: str) -> MediaRecord:
|
||||
text = value.strip().strip('"')
|
||||
absolute = os.path.isabs(text) or bool(re.match(r"^[A-Za-z]:[\\/]", text) or text.startswith("\\\\"))
|
||||
candidates = records if absolute else [record for record in records if record.path.name.lower() == text.lower()]
|
||||
if not candidates and not absolute:
|
||||
candidates = [record for record in records if record.path.stem.lower() == text.lower()]
|
||||
if absolute:
|
||||
path = Path(text)
|
||||
if not path.is_file():
|
||||
raise ValueError("File not found")
|
||||
if path.suffix.lower() not in IMAGE_EXTS:
|
||||
raise ValueError("Only photos are supported")
|
||||
metadata = run_exiftool_json(executable, [path.resolve()])
|
||||
candidates = build_records([path.resolve()], metadata)
|
||||
photos = [record for record in candidates if record.is_image]
|
||||
if not photos:
|
||||
if candidates:
|
||||
raise ValueError("Only photos are supported")
|
||||
raise ValueError("Timeshift syntax error, or no such file was given")
|
||||
if len(photos) != 1:
|
||||
raise ValueError("Multiple photos matched. Please use an absolute path")
|
||||
if photos[0].original_time is None:
|
||||
raise ValueError("Reference photo has no capture timestamp")
|
||||
return photos[0]
|
||||
|
||||
|
||||
def option_text(text: str, selected: bool, active: bool = True) -> str:
|
||||
value = f"{text}"
|
||||
if not active:
|
||||
return dim(value)
|
||||
return inverse(value) if selected else value
|
||||
|
||||
|
||||
def timezone_label(analysis: TimezoneAnalysis) -> str:
|
||||
if not analysis.ordered_zones:
|
||||
return "none"
|
||||
if analysis.shifts > 2:
|
||||
return "many timezones"
|
||||
return ", ".join(timezone_to_string(zone) for zone in analysis.ordered_zones)
|
||||
|
||||
|
||||
def default_artist(records: list[MediaRecord]) -> str:
|
||||
values = {
|
||||
str(value).strip()
|
||||
for record in records
|
||||
for tag in ("EXIF:Artist", "XMP:Artist", "EXIF:Author", "XMP:Author")
|
||||
if (value := record.metadata.get(tag)) and str(value).strip()
|
||||
}
|
||||
return values.pop() if len(values) == 1 else ""
|
||||
|
||||
|
||||
def settings_lines(
|
||||
state: SettingsState,
|
||||
analysis: TimezoneAnalysis,
|
||||
working_dir: Path,
|
||||
rows: list[str],
|
||||
row: int,
|
||||
option: int,
|
||||
*,
|
||||
editor: str | None = None,
|
||||
text: str = "",
|
||||
message: str = "",
|
||||
reference: MediaRecord | None = None,
|
||||
) -> list[str]:
|
||||
selected = rows[row]
|
||||
time_value = "none" if state.time_offset is None else format_timedelta(state.time_offset)
|
||||
time_options = [
|
||||
option_text("none", option == 0),
|
||||
option_text(time_value if state.time_offset else "shift", option == 1),
|
||||
]
|
||||
rename_names = ["Adjust/replace", "Adjust/add", "Replace", "Add"]
|
||||
rename_options = [option_text(name, option == index) for index, name in enumerate(rename_names)]
|
||||
fixed = timezone_to_string(state.fixed_timezone)
|
||||
fixed_option = f"Set {fixed} ({'on' if state.fixed_timezone_enabled else 'off'})"
|
||||
timezone_options = [
|
||||
option_text(fixed_option, option == 0),
|
||||
option_text("Fill gaps", option == 1, analysis.gap_count > 0),
|
||||
]
|
||||
group_options = [option_text("Yes" if state.organize_files else "No", option == 0)]
|
||||
if selected == "organize" or state.organize_files:
|
||||
group_options.extend(
|
||||
(
|
||||
option_text(f"{state.group_min_size}+ files", option == 1, state.organize_files),
|
||||
option_text(".pto", option == 2, state.organize_files and state.process_pto_files),
|
||||
)
|
||||
)
|
||||
if selected != "time":
|
||||
time_options = [time_value]
|
||||
if selected != "rename":
|
||||
rename_options = [rename_names[("adjust_replace", "adjust_add", "replace", "add").index(state.rename_mode)]]
|
||||
if selected != "timezone":
|
||||
enabled = []
|
||||
if state.fixed_timezone_enabled:
|
||||
enabled.append(f"Set {fixed}")
|
||||
if state.fill_timezone_gaps:
|
||||
enabled.append("Fill gaps")
|
||||
timezone_options = enabled or ["none"]
|
||||
artist = state.artist_value if state.artist_enabled else dim("no change")
|
||||
if state.artist_enabled and not artist:
|
||||
artist = light_red("(remove)")
|
||||
if selected == "artist":
|
||||
artist = option_text("no change", True) if not state.artist_enabled else option_text(artist, True)
|
||||
if selected != "organize" and not state.organize_files:
|
||||
group_options = ["No"]
|
||||
lines = [
|
||||
f"Working directory: {working_dir}",
|
||||
"",
|
||||
f"Time correction: {' '.join(time_options)}",
|
||||
]
|
||||
if "infer" in rows:
|
||||
lines.append(f"Infer missing timestamps: {option_text('Yes' if state.infer_missing_timestamps else 'No', selected == 'infer')}")
|
||||
lines.extend(
|
||||
[
|
||||
f"Timezone offsets: Current offsets: {timezone_label(analysis)}",
|
||||
f"{'':28}{' '.join(timezone_options)}",
|
||||
f"Rename files to timestamps: {' '.join(rename_options)}",
|
||||
f"Artist/author: {artist}",
|
||||
f"Organize/group: {' '.join(group_options)}",
|
||||
]
|
||||
)
|
||||
if "confirm" in rows:
|
||||
confirm_options = [option_text("Confirm", option == 0), option_text("Exit", option == 1)]
|
||||
lines.extend(["", f"{'':28}{' '.join(confirm_options) if selected == 'confirm' else 'Confirm Exit'}"])
|
||||
if editor:
|
||||
lines.extend(["", light_red(message)] if message else [""])
|
||||
if editor == "time-source":
|
||||
lines.extend(["Enter timeshift (+/-h:mm:ss or +/-m:ss),", "or filename for photo used as a reference:"])
|
||||
elif editor == "time-clock":
|
||||
assert reference is not None
|
||||
lines.extend([f'File "{reference.path.name}" selected as reference.', "Enter the time that this file should have (h:mm:ss):"])
|
||||
lines.append("> " + (dark_field(text.ljust(6)) if editor == "timezone" else text))
|
||||
elif message:
|
||||
lines.extend(["", message])
|
||||
controls = ["Enter: confirm"]
|
||||
if not editor:
|
||||
controls.insert(0, "Arrow keys: navigate")
|
||||
if not editor and selected in {"infer", "timezone", "artist", "organize"}:
|
||||
controls.append("Space: toggle")
|
||||
if editor:
|
||||
controls.append("Esc: undo")
|
||||
lines.extend(["", " ".join(controls)])
|
||||
return lines
|
||||
|
||||
|
||||
def prompt_choices(
|
||||
executable: str,
|
||||
records: list[MediaRecord],
|
||||
working_dir: Path,
|
||||
has_pto_files: bool,
|
||||
initial_state: SettingsState | None = None,
|
||||
) -> tuple[UserChoices | None, SettingsState]:
|
||||
|
||||
analysis = analyze_timezones(records)
|
||||
first_zone = analysis.ordered_zones[0] if analysis.ordered_zones else local_timezone()
|
||||
state = copy.deepcopy(initial_state) if initial_state else SettingsState(
|
||||
time_offset=None,
|
||||
infer_missing_timestamps=True,
|
||||
fixed_timezone=first_zone,
|
||||
fixed_timezone_enabled=False,
|
||||
fill_timezone_gaps=analysis.gap_count > 0 and analysis.shifts <= 2 and not analysis.conflicting_gap_count,
|
||||
process_pto_files=has_pto_files,
|
||||
artist_value=default_artist(records),
|
||||
)
|
||||
rows = ["time"]
|
||||
if any(record.original_time is None for record in records):
|
||||
rows.append("infer")
|
||||
rows.extend(["timezone", "rename", "artist", "organize", "confirm"])
|
||||
row = 0
|
||||
option = 1 if state.time_offset is not None else 0
|
||||
editor = None
|
||||
text = message = ""
|
||||
reference = None
|
||||
|
||||
def enter_row(index: int) -> None:
|
||||
nonlocal row, option
|
||||
row = index % len(rows)
|
||||
if rows[row] == "rename":
|
||||
option = ("adjust_replace", "adjust_add", "replace", "add").index(state.rename_mode)
|
||||
elif rows[row] == "time":
|
||||
option = 1 if state.time_offset is not None else 0
|
||||
else:
|
||||
option = 0
|
||||
|
||||
def finish_editor() -> None:
|
||||
nonlocal editor, text, message, reference
|
||||
editor = None
|
||||
text = message = ""
|
||||
reference = None
|
||||
|
||||
with raw_key_input() as next_key:
|
||||
while True:
|
||||
draw_screen(settings_lines(state, analysis, working_dir, rows, row, option, editor=editor, text=text, message=message, reference=reference))
|
||||
key = next_key()
|
||||
if editor:
|
||||
if key == "esc":
|
||||
finish_editor()
|
||||
elif key == "backspace":
|
||||
text = text[:-1]
|
||||
elif key == "enter":
|
||||
if editor == "timezone":
|
||||
try:
|
||||
state.fixed_timezone = parse_timezone_offset(text)
|
||||
except ValueError:
|
||||
message = "Invalid timezone offset"
|
||||
else:
|
||||
state.fixed_timezone_enabled = True
|
||||
finish_editor()
|
||||
elif editor == "artist":
|
||||
state.artist_value = text
|
||||
state.artist_enabled = True
|
||||
finish_editor()
|
||||
elif editor == "group":
|
||||
if text.isdigit() and int(text) >= 2:
|
||||
state.group_min_size = int(text)
|
||||
finish_editor()
|
||||
else:
|
||||
message = "Group size must be 2 or more"
|
||||
elif editor == "time-source":
|
||||
try:
|
||||
state.time_offset = parse_timeshift(text)
|
||||
except ValueError:
|
||||
try:
|
||||
reference = reference_record(executable, records, text)
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
else:
|
||||
editor, text, message = "time-clock", "", ""
|
||||
else:
|
||||
finish_editor()
|
||||
enter_row(row + 1)
|
||||
else:
|
||||
try:
|
||||
assert reference is not None and reference.original_time is not None
|
||||
state.time_offset = parse_clock_time(text, naive_wall_time(reference.original_time)) - naive_wall_time(reference.original_time)
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
else:
|
||||
finish_editor()
|
||||
enter_row(row + 1)
|
||||
elif key == "space" and editor != "timezone":
|
||||
text += " "
|
||||
elif len(key) == 1 and (editor != "timezone" or key in "+-:0123456789"):
|
||||
if editor != "timezone" or len(text) < 6:
|
||||
text += key
|
||||
continue
|
||||
|
||||
if key == "up":
|
||||
enter_row(row - 1)
|
||||
continue
|
||||
current = rows[row]
|
||||
if current == "confirm" and key == "enter":
|
||||
if option == 1:
|
||||
return None, state
|
||||
apply_timezone_settings(records, state)
|
||||
artist_action = "leave" if not state.artist_enabled else ("set" if state.artist_value else "clear")
|
||||
return UserChoices(
|
||||
working_dir=working_dir,
|
||||
time_offset=state.time_offset,
|
||||
fixed_timezone=state.fixed_timezone if state.fixed_timezone_enabled else None,
|
||||
fill_timezone_gaps=state.fill_timezone_gaps,
|
||||
artist_action=artist_action,
|
||||
artist_value=state.artist_value or None,
|
||||
rename_mode=state.rename_mode,
|
||||
organize_files=state.organize_files,
|
||||
group_min_size=state.group_min_size,
|
||||
process_pto_files=state.organize_files and state.process_pto_files,
|
||||
infer_missing_timestamps=state.infer_missing_timestamps,
|
||||
), state
|
||||
if key == "down" or (key == "enter" and current != "time"):
|
||||
enter_row(row + 1)
|
||||
continue
|
||||
|
||||
if current == "time":
|
||||
if key == "left":
|
||||
option = 0
|
||||
state.time_offset = None
|
||||
elif key == "right":
|
||||
option = 1
|
||||
if state.time_offset is None:
|
||||
editor, text = "time-source", ""
|
||||
elif key == "enter" and option == 1:
|
||||
editor, text = "time-source", ""
|
||||
elif key == "enter":
|
||||
enter_row(row + 1)
|
||||
elif current == "infer" and key in {"left", "right", "space"}:
|
||||
state.infer_missing_timestamps = not state.infer_missing_timestamps
|
||||
elif current == "timezone":
|
||||
if key == "left":
|
||||
option = max(0, option - 1)
|
||||
elif key == "right":
|
||||
option = min(1 if analysis.gap_count and not analysis.conflicting_gap_count else 0, option + 1)
|
||||
elif key == "space":
|
||||
if option == 0:
|
||||
state.fixed_timezone_enabled = not state.fixed_timezone_enabled
|
||||
elif analysis.gap_count and not analysis.conflicting_gap_count:
|
||||
state.fill_timezone_gaps = not state.fill_timezone_gaps
|
||||
elif option == 0 and key in "+-0123456789":
|
||||
editor, text = "timezone", ("+" if key.isdigit() else "") + key
|
||||
elif current == "rename":
|
||||
if key == "left":
|
||||
option = max(0, option - 1)
|
||||
elif key == "right":
|
||||
option = min(3, option + 1)
|
||||
state.rename_mode = ("adjust_replace", "adjust_add", "replace", "add")[option]
|
||||
elif current == "artist":
|
||||
if key == "space":
|
||||
state.artist_enabled = not state.artist_enabled
|
||||
elif len(key) == 1:
|
||||
editor, text = "artist", key
|
||||
elif current == "organize":
|
||||
if key == "left":
|
||||
option = max(0, option - 1)
|
||||
elif key == "right" and state.organize_files:
|
||||
option = min(2, option + 1)
|
||||
elif key == "space":
|
||||
if option == 0:
|
||||
state.organize_files = not state.organize_files
|
||||
elif option == 2:
|
||||
state.process_pto_files = not state.process_pto_files
|
||||
elif option == 1 and key.isdigit():
|
||||
editor, text = "group", key
|
||||
elif current == "confirm" and key in {"left", "right"}:
|
||||
option = 1 - option
|
||||
|
||||
+1
-46
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from tools.executables import require_executable
|
||||
from tools.media_metadata import first_tag, parse_exif_datetime, round_half_up
|
||||
|
||||
|
||||
VIDEO_TIME_TAGS = (
|
||||
@@ -49,13 +50,6 @@ GPS_POSITION_TAGS = (
|
||||
"Composite:GPSCoordinates",
|
||||
)
|
||||
|
||||
DATETIME_RE = re.compile(
|
||||
r"(?P<Y>\d{4})[:\-]?(?P<M>\d{2})[:\-]?(?P<D>\d{2})"
|
||||
r"(?:[ T_])?"
|
||||
r"(?P<h>\d{2}):?(?P<m>\d{2}):?(?P<s>\d{2})"
|
||||
r"(?:[.,](?P<sub>\d+))?"
|
||||
r"(?:\s*(?P<tz>Z|[+-]\d{2}:?\d{2}))?"
|
||||
)
|
||||
DMS_RE = re.compile(
|
||||
r"(?P<deg>-?\d+(?:\.\d+)?)\D+"
|
||||
r"(?P<min>\d+(?:\.\d+)?)?\D*"
|
||||
@@ -385,13 +379,6 @@ def summarize_frame_timing(timestamps: list[float]) -> FrameTimingSummary:
|
||||
)
|
||||
|
||||
|
||||
def first_tag(metadata: dict, tags: tuple[str, ...]) -> object | None:
|
||||
for tag in tags:
|
||||
if tag in metadata:
|
||||
return metadata[tag]
|
||||
return None
|
||||
|
||||
|
||||
def _gps_coordinates(metadata: dict) -> tuple[float | None, float | None]:
|
||||
latitude = _parse_gps_coordinate(first_tag(metadata, GPS_LATITUDE_TAGS))
|
||||
longitude = _parse_gps_coordinate(first_tag(metadata, GPS_LONGITUDE_TAGS))
|
||||
@@ -449,34 +436,6 @@ def _parse_gps_coordinate(value: object) -> float | None:
|
||||
return sign * (degrees + minutes / 60.0 + seconds / 3600.0)
|
||||
|
||||
|
||||
def parse_exif_datetime(value: object, assume_utc: bool = False) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
match = DATETIME_RE.search(str(value).strip())
|
||||
if not match:
|
||||
return None
|
||||
|
||||
tzinfo = None
|
||||
tz_text = match.group("tz")
|
||||
if tz_text == "Z":
|
||||
tzinfo = timezone.utc
|
||||
elif tz_text:
|
||||
tzinfo = _parse_timezone_offset(tz_text)
|
||||
|
||||
parsed = datetime(
|
||||
int(match.group("Y")),
|
||||
int(match.group("M")),
|
||||
int(match.group("D")),
|
||||
int(match.group("h")),
|
||||
int(match.group("m")),
|
||||
int(match.group("s")),
|
||||
tzinfo=tzinfo,
|
||||
)
|
||||
if assume_utc and parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed
|
||||
|
||||
|
||||
def _parse_timezone_offset(value: str) -> timezone:
|
||||
text = value.replace(":", "")
|
||||
sign = 1 if text[0] == "+" else -1
|
||||
@@ -495,10 +454,6 @@ def _video_timezone(metadata: dict) -> timezone | None:
|
||||
return _parse_timezone_offset(text)
|
||||
|
||||
|
||||
def round_half_up(value: float) -> int:
|
||||
return int(value + 0.5)
|
||||
|
||||
|
||||
def _to_float(value: object) -> float | None:
|
||||
if value is None or value == "N/A":
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user