Refactor photo metadata workflow
This commit is contained in:
@@ -20,23 +20,26 @@ Current features:
|
|||||||
- reads and writes metadata through `exiftool` from `PATH`
|
- reads and writes metadata through `exiftool` from `PATH`
|
||||||
- optional direct time shifts, such as `+1:30`, `-02:00:00`, or `+1h 2m`
|
- optional direct time shifts, such as `+1:30`, `-02:00:00`, or `+1h 2m`
|
||||||
- optional reference-clock correction using a source photo timestamp or filename plus the correct time
|
- optional reference-clock correction using a source photo timestamp or filename plus the correct time
|
||||||
- timezone handling for photos and local-time video filenames
|
- keyboard-driven settings table with timezone gap filling and optional fixed timezone offsets
|
||||||
- artist/author set, clear, or leave unchanged
|
- artist/author set, clear, or leave unchanged
|
||||||
- timestamp-based renaming to `YYYYMMDD_HHMMSS.ext`
|
- timestamp-based rename modes: adjust timestamps already in filenames, add one, or replace the stem
|
||||||
- video filenames use the beginning timestamp by subtracting rounded duration from the video metadata timestamp
|
- video filenames use the beginning timestamp by subtracting rounded duration from the video metadata timestamp
|
||||||
- Sony-style video sidecars like `C0011.MP4` plus `C0011M01.XML` are moved and renamed with the video
|
- Sony-style video sidecars like `C0011.MP4` plus `C0011M01.XML` are moved and renamed with the video
|
||||||
- automatic photo burst/HDR grouping per camera
|
- automatic photo burst/HDR grouping per camera
|
||||||
- optional panorama project (`.pto`) grouping and reference updates
|
- optional panorama project (`.pto`) grouping and reference updates
|
||||||
- inferred subsecond metadata for grouped same-second photo bursts
|
- inferred subsecond metadata for grouped same-second photo bursts when the group has no existing subseconds
|
||||||
|
- optional capture-time inference from a safe timestamp filename, then `ModifyDate` as a fallback
|
||||||
|
|
||||||
The working directory is determined from the dragged items. A single dragged folder is the working directory. For multiple dragged files/folders, their last common ancestor is the working directory. Automatic burst/HDR and `.pto` group folders are created inside that working directory.
|
The working directory is determined from the dragged items. A single dragged folder is the working directory. For multiple dragged files/folders, their last common ancestor is the working directory. Automatic burst/HDR and `.pto` group folders are created inside that working directory.
|
||||||
|
|
||||||
If dragged folders contain files in subdirectories and grouping is enabled, ungrouped files are moved into the working directory too. This makes the run organize everything into a deterministic flat reset state, with grouped files in their group folders and the remaining files directly in the working directory. If grouping is disabled, the script asks whether to flatten subdirectory files into the working directory.
|
The `Organize/group` setting controls all file movement. When enabled, ungrouped files are moved into the working directory and groups are created there; when disabled, files remain in their original directories. Panorama-project grouping is also enabled from that row.
|
||||||
|
|
||||||
Panorama project files use the `.pto` extension. When enabled, every media file referenced by a `.pto` file belongs to the same group. If those files already belong to burst/HDR groups, the groups are merged. `.pto` files are moved into the group folder with their referenced files, renamed from the first and last referenced media stems, and their file references are rewritten to the new relative paths.
|
Panorama project files use the `.pto` extension. When enabled, every media file referenced by a `.pto` file belongs to the same group. If those files already belong to burst/HDR groups, the groups are merged. `.pto` files are moved into the group folder with their referenced files, renamed from the first and last referenced media stems, and their file references are rewritten to the new relative paths.
|
||||||
|
|
||||||
Reference-clock correction is useful when one photo shows a reliable clock. For example, if `DSC01234.JPG` has camera metadata `2026:06:02 22:10:00`, but the clock in the photo shows `22:13:25`, choose reference mode, enter `DSC01234.JPG` as the source, and enter `22:13:25` as the correct time. The script computes a `+00:03:25` shift and applies that correction to the selected files. If you omit the date in the correct time, the script chooses the date closest to the source timestamp, within 12 hours.
|
Reference-clock correction is useful when one photo shows a reliable clock. For example, if `DSC01234.JPG` has camera metadata `2026:06:02 22:10:00`, but the clock in the photo shows `22:13:25`, choose reference mode, enter `DSC01234.JPG` as the source, and enter `22:13:25` as the correct time. The script computes a `+00:03:25` shift and applies that correction to the selected files. If you omit the date in the correct time, the script chooses the date closest to the source timestamp, within 12 hours.
|
||||||
|
|
||||||
|
The settings table requires an ANSI-capable terminal because it uses arrow keys and inline editing. Use Up/Down to select a row, Left/Right to select an option, Space to toggle an option, and Enter to continue. The preview separates common changes, inferred timestamps, warnings, and file operations before asking for confirmation.
|
||||||
|
|
||||||
### `grouping.py`
|
### `grouping.py`
|
||||||
|
|
||||||
Fast manual grouping for selected files:
|
Fast manual grouping for selected files:
|
||||||
|
|||||||
+44
-1565
File diff suppressed because it is too large
Load Diff
+178
-29
@@ -1,14 +1,19 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
import select
|
||||||
|
import shutil
|
||||||
import sys
|
import sys
|
||||||
|
import textwrap
|
||||||
import time
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
from contextlib import contextmanager
|
||||||
from math import floor, log10
|
from math import floor, log10
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
|
|
||||||
|
|
||||||
_WINDOWS_ANSI_ENABLED: bool | None = None
|
_WINDOWS_ANSI_ENABLED: bool | None = None
|
||||||
|
_WINDOWS_CONSOLE_OUTPUT: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
def prompt_input(prompt: str) -> str:
|
def prompt_input(prompt: str) -> str:
|
||||||
@@ -44,7 +49,7 @@ def supports_color() -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def supports_ansi() -> bool:
|
def supports_ansi() -> bool:
|
||||||
if not sys.stdout.isatty():
|
if not stream_is_interactive(sys.stdout):
|
||||||
return False
|
return False
|
||||||
if os.name != "nt":
|
if os.name != "nt":
|
||||||
return os.environ.get("TERM", "dumb") != "dumb"
|
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:
|
def color(text: object, code: str) -> str:
|
||||||
value = str(text)
|
value = str(text)
|
||||||
if not supports_color():
|
if not supports_color():
|
||||||
@@ -79,12 +88,74 @@ def red_strikethrough(text: object) -> str:
|
|||||||
return color(text, "91;9")
|
return color(text, "91;9")
|
||||||
|
|
||||||
|
|
||||||
def clear_screen() -> None:
|
def clear_screen(*, scrollback: bool = False) -> None:
|
||||||
if not sys.stdout.isatty():
|
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
|
return
|
||||||
os.system("cls" if os.name == "nt" else "clear")
|
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:
|
def _enable_windows_ansi() -> bool:
|
||||||
global _WINDOWS_ANSI_ENABLED
|
global _WINDOWS_ANSI_ENABLED
|
||||||
if _WINDOWS_ANSI_ENABLED is not None:
|
if _WINDOWS_ANSI_ENABLED is not None:
|
||||||
@@ -105,6 +176,23 @@ def _enable_windows_ansi() -> bool:
|
|||||||
return False
|
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:
|
def format_duration(seconds: float) -> str:
|
||||||
seconds = max(0, int(round(seconds)))
|
seconds = max(0, int(round(seconds)))
|
||||||
minutes, second = divmod(seconds, 60)
|
minutes, second = divmod(seconds, 60)
|
||||||
@@ -120,30 +208,42 @@ class ProgressView:
|
|||||||
total: int,
|
total: int,
|
||||||
label: str,
|
label: str,
|
||||||
*,
|
*,
|
||||||
|
total_work: float | None = None,
|
||||||
stream=None,
|
stream=None,
|
||||||
embedded_percent: bool = False,
|
embedded_percent: bool = False,
|
||||||
show_rate: bool = False,
|
show_rate: bool = False,
|
||||||
|
show_elapsed: bool = True,
|
||||||
|
bar_width: int = 24,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.total = max(0, total)
|
self.total = max(0, total)
|
||||||
|
self.total_work = total if total_work is None else max(0.0, total_work)
|
||||||
self.label = label
|
self.label = label
|
||||||
self.stream = stream or sys.stdout
|
self.stream = stream or sys.stdout
|
||||||
self.started_at = time.monotonic()
|
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_width = 0
|
||||||
|
self.last_line_count = 0
|
||||||
self.embedded_percent = embedded_percent
|
self.embedded_percent = embedded_percent
|
||||||
self.show_rate = show_rate
|
self.show_rate = show_rate
|
||||||
|
self.show_elapsed = show_elapsed
|
||||||
|
self.bar_width = bar_width
|
||||||
|
|
||||||
def update(self, processed: int, detail: str = "") -> None:
|
def update(
|
||||||
self._write(self.update_line(processed, detail))
|
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))
|
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()
|
now = time.monotonic()
|
||||||
if not self.samples or self.samples[-1][0] != processed:
|
if not self.samples or self.samples[-1][0] != work_done:
|
||||||
self.samples.append((processed, now))
|
self.samples.append((work_done, now))
|
||||||
while len(self.samples) > max(2, processed // 2 + 2):
|
while len(self.samples) > max(2, processed // 2 + 2):
|
||||||
self.samples.popleft()
|
self.samples.popleft()
|
||||||
return self._line(processed, detail, now)
|
return self._line(processed, detail, now, completed_work=work_done)
|
||||||
|
|
||||||
def update_external(
|
def update_external(
|
||||||
self,
|
self,
|
||||||
@@ -167,25 +267,60 @@ class ProgressView:
|
|||||||
return self._line(processed, detail, None, elapsed=elapsed, eta=eta)
|
return self._line(processed, detail, None, elapsed=elapsed, eta=eta)
|
||||||
|
|
||||||
def finish(self, *, keep: bool = False) -> None:
|
def finish(self, *, keep: bool = False) -> None:
|
||||||
if self.stream.isatty():
|
if stream_is_interactive(self.stream):
|
||||||
if keep:
|
if keep:
|
||||||
self.stream.write("\n")
|
self.stream.write("\n")
|
||||||
else:
|
else:
|
||||||
self.stream.write("\r" + " " * self.last_width + "\r")
|
self._clear_live_lines()
|
||||||
self.stream.flush()
|
self.stream.flush()
|
||||||
elif self.last_width and not keep:
|
elif self.last_width and not keep:
|
||||||
self.stream.write("\n")
|
self.stream.write("\n")
|
||||||
self.last_width = 0
|
self.last_width = 0
|
||||||
|
self.last_line_count = 0
|
||||||
|
|
||||||
def _write(self, line: str) -> None:
|
def write_lines(self, lines: list[str]) -> None:
|
||||||
if self.stream.isatty():
|
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))
|
padding = max(0, self.last_width - len(line))
|
||||||
self.stream.write("\r" + line + (" " * padding))
|
self.stream.write("\r" + line + (" " * padding))
|
||||||
self.stream.flush()
|
self.stream.flush()
|
||||||
self.last_width = len(line)
|
self.last_width = len(line)
|
||||||
else:
|
else:
|
||||||
self.stream.write(line + "\n")
|
self.stream.write("\n".join(lines) + "\n")
|
||||||
self.last_width = len(line)
|
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(
|
def _line(
|
||||||
self,
|
self,
|
||||||
@@ -195,13 +330,15 @@ class ProgressView:
|
|||||||
*,
|
*,
|
||||||
elapsed: float | None = None,
|
elapsed: float | None = None,
|
||||||
eta: float | None = None,
|
eta: float | None = None,
|
||||||
|
completed_work: float | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
prefix = self.label if not detail else f"{self.label}: {detail}"
|
prefix = self.label
|
||||||
if self.total <= 2:
|
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
|
work_done = processed if completed_work is None else completed_work
|
||||||
bar = _progress_bar(percent, embedded_percent=self.embedded_percent)
|
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
|
elapsed_value = elapsed if elapsed is not None else (now or time.monotonic()) - self.started_at
|
||||||
parts = [
|
parts = [
|
||||||
prefix,
|
prefix,
|
||||||
@@ -212,22 +349,25 @@ class ProgressView:
|
|||||||
parts.append(f"{percent * 100:5.1f}%")
|
parts.append(f"{percent * 100:5.1f}%")
|
||||||
if self.show_rate and processed > 0 and elapsed_value > 0:
|
if self.show_rate and processed > 0 and elapsed_value > 0:
|
||||||
parts.append(f"{_format_significant(processed / elapsed_value, 2)} fps")
|
parts.append(f"{_format_significant(processed / elapsed_value, 2)} fps")
|
||||||
|
if self.show_elapsed:
|
||||||
parts.append(f"elapsed {format_duration(elapsed_value)}")
|
parts.append(f"elapsed {format_duration(elapsed_value)}")
|
||||||
eta_value = eta if eta is not None else self._eta(processed)
|
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:
|
if processed >= 2 and processed < self.total and eta_value is not None:
|
||||||
parts.append(f"ETA {format_duration(eta_value)}")
|
parts.append(f"ETA {format_duration(eta_value)}")
|
||||||
|
if detail:
|
||||||
|
parts.append(detail)
|
||||||
return " ".join(parts)
|
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:
|
if processed < 2 or self.total <= processed or len(self.samples) < 2:
|
||||||
return None
|
return None
|
||||||
oldest_processed, oldest_time = self.samples[0]
|
oldest_work, oldest_time = self.samples[0]
|
||||||
newest_processed, newest_time = self.samples[-1]
|
newest_work, newest_time = self.samples[-1]
|
||||||
delta_items = newest_processed - oldest_processed
|
delta_work = newest_work - oldest_work
|
||||||
delta_time = newest_time - oldest_time
|
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 None
|
||||||
return (self.total - processed) * (delta_time / delta_items)
|
return (self.total_work - work_done) * (delta_time / delta_work)
|
||||||
|
|
||||||
|
|
||||||
class PipelineProgressView:
|
class PipelineProgressView:
|
||||||
@@ -311,8 +451,7 @@ class PipelineProgressView:
|
|||||||
self._shown = True
|
self._shown = True
|
||||||
|
|
||||||
|
|
||||||
def _progress_bar(percent: float, *, embedded_percent: bool) -> str:
|
def _progress_bar(percent: float, *, embedded_percent: bool, width: int = 24) -> str:
|
||||||
width = 24
|
|
||||||
done = round(max(0.0, min(percent, 1.0)) * width)
|
done = round(max(0.0, min(percent, 1.0)) * width)
|
||||||
if not embedded_percent:
|
if not embedded_percent:
|
||||||
return "[" + ("#" * done).ljust(width, "-") + "]"
|
return "[" + ("#" * done).ljust(width, "-") + "]"
|
||||||
@@ -335,6 +474,16 @@ def _center_progress_text(text: str, width: int, fill: str) -> str:
|
|||||||
return content.center(width, fill)
|
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:
|
def _format_significant(value: float, digits: int) -> str:
|
||||||
if value == 0:
|
if value == 0:
|
||||||
return "0"
|
return "0"
|
||||||
|
|||||||
+63
-13
@@ -2,8 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
@@ -18,15 +21,12 @@ def _use_windows_argument_file() -> bool:
|
|||||||
return os.name == "nt"
|
return os.name == "nt"
|
||||||
|
|
||||||
|
|
||||||
def run_exiftool_command(
|
@contextmanager
|
||||||
executable: str,
|
def _prepared_command(executable: str, args: list[str]) -> Iterator[tuple[list[str], str | None]]:
|
||||||
args: list[str],
|
|
||||||
**run_kwargs: Any,
|
|
||||||
) -> subprocess.CompletedProcess:
|
|
||||||
"""Run ExifTool with Unicode-safe filename arguments on Windows."""
|
|
||||||
command = [executable, "-charset", "filename=UTF8", *args]
|
command = [executable, "-charset", "filename=UTF8", *args]
|
||||||
if not _use_windows_argument_file():
|
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
|
# 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
|
# 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:
|
with tempfile.TemporaryDirectory(prefix="mbt-exiftool-") as temp_dir:
|
||||||
argument_file = Path(temp_dir) / "arguments.txt"
|
argument_file = Path(temp_dir) / "arguments.txt"
|
||||||
argument_file.write_text("\n".join(args) + "\n", encoding="utf-8")
|
argument_file.write_text("\n".join(args) + "\n", encoding="utf-8")
|
||||||
return subprocess.run(
|
yield [executable, "-charset", "filename=UTF8", "-@", argument_file.name], temp_dir
|
||||||
[executable, "-charset", "filename=UTF8", "-@", argument_file.name],
|
|
||||||
cwd=temp_dir,
|
|
||||||
**run_kwargs,
|
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(
|
def run_exiftool_json(
|
||||||
@@ -46,6 +55,7 @@ def run_exiftool_json(
|
|||||||
paths: list[Path],
|
paths: list[Path],
|
||||||
*,
|
*,
|
||||||
quicktime_utc: bool = True,
|
quicktime_utc: bool = True,
|
||||||
|
progress: Callable[[], None] | None = None,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
results: list[dict] = []
|
results: list[dict] = []
|
||||||
for start in range(0, len(paths), 80):
|
for start in range(0, len(paths), 80):
|
||||||
@@ -57,6 +67,7 @@ def run_exiftool_json(
|
|||||||
if quicktime_utc:
|
if quicktime_utc:
|
||||||
args.extend(["-api", "QuickTimeUTC=1"])
|
args.extend(["-api", "QuickTimeUTC=1"])
|
||||||
args.extend(str(path) for path in chunk)
|
args.extend(str(path) for path in chunk)
|
||||||
|
if progress is None:
|
||||||
completed = run_exiftool_command(
|
completed = run_exiftool_command(
|
||||||
executable,
|
executable,
|
||||||
args,
|
args,
|
||||||
@@ -65,6 +76,36 @@ def run_exiftool_json(
|
|||||||
text=True,
|
text=True,
|
||||||
)
|
)
|
||||||
results.extend(json.loads(completed.stdout or "[]"))
|
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
|
return results
|
||||||
|
|
||||||
|
|
||||||
@@ -76,4 +117,13 @@ def run_exiftool_write(executable: str, path: Path, args: list[str]) -> None:
|
|||||||
*args,
|
*args,
|
||||||
str(path),
|
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)
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import shutil
|
|
||||||
import subprocess
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from tools.exiftool import run_exiftool_command
|
from tools.exiftool import run_exiftool_command
|
||||||
from tools.filesystem import unique_path
|
|
||||||
|
|
||||||
|
|
||||||
EXCLUDED_COPY_TAGS = [
|
EXCLUDED_COPY_TAGS = [
|
||||||
@@ -70,15 +67,12 @@ def copy_meaningful_metadata(
|
|||||||
*,
|
*,
|
||||||
extra_excluded_tags: list[str] | None = None,
|
extra_excluded_tags: list[str] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if destination.suffix.lower() in {".mp4", ".mov"}:
|
|
||||||
copy_meaningful_metadata_with_exiftool(
|
copy_meaningful_metadata_with_exiftool(
|
||||||
exiftool,
|
exiftool,
|
||||||
source,
|
source,
|
||||||
destination,
|
destination,
|
||||||
extra_excluded_tags=extra_excluded_tags,
|
extra_excluded_tags=extra_excluded_tags,
|
||||||
)
|
)
|
||||||
return
|
|
||||||
copy_container_metadata_with_ffmpeg(source, destination)
|
|
||||||
|
|
||||||
|
|
||||||
def copy_meaningful_metadata_with_exiftool(
|
def copy_meaningful_metadata_with_exiftool(
|
||||||
@@ -104,37 +98,3 @@ def copy_meaningful_metadata_with_exiftool(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=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 pathlib import Path
|
||||||
|
|
||||||
from tools.executables import require_executable
|
from tools.executables import require_executable
|
||||||
|
from tools.media_metadata import first_tag, parse_exif_datetime, round_half_up
|
||||||
|
|
||||||
|
|
||||||
VIDEO_TIME_TAGS = (
|
VIDEO_TIME_TAGS = (
|
||||||
@@ -49,13 +50,6 @@ GPS_POSITION_TAGS = (
|
|||||||
"Composite:GPSCoordinates",
|
"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(
|
DMS_RE = re.compile(
|
||||||
r"(?P<deg>-?\d+(?:\.\d+)?)\D+"
|
r"(?P<deg>-?\d+(?:\.\d+)?)\D+"
|
||||||
r"(?P<min>\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]:
|
def _gps_coordinates(metadata: dict) -> tuple[float | None, float | None]:
|
||||||
latitude = _parse_gps_coordinate(first_tag(metadata, GPS_LATITUDE_TAGS))
|
latitude = _parse_gps_coordinate(first_tag(metadata, GPS_LATITUDE_TAGS))
|
||||||
longitude = _parse_gps_coordinate(first_tag(metadata, GPS_LONGITUDE_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)
|
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:
|
def _parse_timezone_offset(value: str) -> timezone:
|
||||||
text = value.replace(":", "")
|
text = value.replace(":", "")
|
||||||
sign = 1 if text[0] == "+" else -1
|
sign = 1 if text[0] == "+" else -1
|
||||||
@@ -495,10 +454,6 @@ def _video_timezone(metadata: dict) -> timezone | None:
|
|||||||
return _parse_timezone_offset(text)
|
return _parse_timezone_offset(text)
|
||||||
|
|
||||||
|
|
||||||
def round_half_up(value: float) -> int:
|
|
||||||
return int(value + 0.5)
|
|
||||||
|
|
||||||
|
|
||||||
def _to_float(value: object) -> float | None:
|
def _to_float(value: object) -> float | None:
|
||||||
if value is None or value == "N/A":
|
if value is None or value == "N/A":
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import unittest
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
from tools.exiftool import run_exiftool_command
|
from tools.exiftool import run_exiftool_command, run_exiftool_write
|
||||||
|
from tools.media_metadata import parse_exif_datetime
|
||||||
|
from tools.metadata_copy import copy_meaningful_metadata
|
||||||
|
|
||||||
|
|
||||||
class ExiftoolInvocationTests(unittest.TestCase):
|
class ExiftoolInvocationTests(unittest.TestCase):
|
||||||
@@ -34,6 +36,36 @@ class ExiftoolInvocationTests(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(captured["contents"], "-j\nC:\\Photos\\M\u00e4rchen.jpg\n")
|
self.assertEqual(captured["contents"], "-j\nC:\\Photos\\M\u00e4rchen.jpg\n")
|
||||||
|
|
||||||
|
def test_write_captures_success_output(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"tools.exiftool.subprocess.run",
|
||||||
|
return_value=subprocess.CompletedProcess([], 0, "1 image files updated\n", ""),
|
||||||
|
) as run:
|
||||||
|
run_exiftool_write("exiftool", Path("photo.jpg"), ["-Artist=Test"])
|
||||||
|
|
||||||
|
self.assertTrue(run.call_args.kwargs["capture_output"])
|
||||||
|
self.assertTrue(run.call_args.kwargs["text"])
|
||||||
|
|
||||||
|
def test_write_includes_exiftool_failure_output(self) -> None:
|
||||||
|
with patch(
|
||||||
|
"tools.exiftool.subprocess.run",
|
||||||
|
return_value=subprocess.CompletedProcess([], 1, "", "Error renaming temporary file"),
|
||||||
|
):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "Error renaming temporary file"):
|
||||||
|
run_exiftool_write("exiftool", Path("photo.jpg"), ["-Artist=Test"])
|
||||||
|
|
||||||
|
def test_metadata_copy_uses_exiftool_for_photos(self) -> None:
|
||||||
|
with patch("tools.metadata_copy.run_exiftool_command") as run:
|
||||||
|
copy_meaningful_metadata("exiftool", Path("source.jpg"), Path("target.jpg"))
|
||||||
|
|
||||||
|
args = run.call_args.args[1]
|
||||||
|
self.assertEqual(args[:4], ["-overwrite_original", "-TagsFromFile", "source.jpg", "-all:all"])
|
||||||
|
self.assertEqual(args[-1], "target.jpg")
|
||||||
|
|
||||||
|
def test_metadata_datetime_can_assume_utc(self) -> None:
|
||||||
|
parsed = parse_exif_datetime("2026:07:04 14:13:36", assume_utc=True)
|
||||||
|
self.assertEqual(str(parsed.tzinfo), "UTC")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from tools.photo_grouping import detect_photo_groups, infer_group_subseconds
|
||||||
|
from tools.photo_planning import (
|
||||||
|
UserChoices,
|
||||||
|
build_write_plan,
|
||||||
|
filename_timestamp_stem,
|
||||||
|
infer_missing_timestamps,
|
||||||
|
merge_record_groups,
|
||||||
|
)
|
||||||
|
from tools.photo_records import (
|
||||||
|
MediaRecord,
|
||||||
|
infer_video_timezone,
|
||||||
|
)
|
||||||
|
from tools.photo_settings import default_artist
|
||||||
|
|
||||||
|
|
||||||
|
class PhotoMetadataLogicTests(unittest.TestCase):
|
||||||
|
def choices(self, **values):
|
||||||
|
base = dict(
|
||||||
|
working_dir=Path("."),
|
||||||
|
time_offset=None,
|
||||||
|
fixed_timezone=None,
|
||||||
|
fill_timezone_gaps=False,
|
||||||
|
artist_action="leave",
|
||||||
|
artist_value=None,
|
||||||
|
rename_mode="adjust_replace",
|
||||||
|
organize_files=False,
|
||||||
|
group_min_size=2,
|
||||||
|
process_pto_files=False,
|
||||||
|
infer_missing_timestamps=True,
|
||||||
|
)
|
||||||
|
base.update(values)
|
||||||
|
return UserChoices(**base)
|
||||||
|
|
||||||
|
def test_adjusted_filename_preserves_each_timestamp_interval(self) -> None:
|
||||||
|
stem = "20260516_130011-20260516_130102_fused"
|
||||||
|
result = filename_timestamp_stem(stem, datetime(2026, 6, 7, 14, 0, 0), "adjust_replace")
|
||||||
|
self.assertEqual(result, "20260607_140000-20260607_140051_fused")
|
||||||
|
|
||||||
|
def test_uses_one_existing_artist_as_the_disabled_default(self) -> None:
|
||||||
|
records = [
|
||||||
|
MediaRecord(Path("a.jpg"), {"EXIF:Artist": "Anton"}, "image"),
|
||||||
|
MediaRecord(Path("b.jpg"), {"XMP:Author": "Anton"}, "image"),
|
||||||
|
]
|
||||||
|
self.assertEqual(default_artist(records), "Anton")
|
||||||
|
|
||||||
|
def test_infers_missing_photo_time_from_filename_before_modify_date(self) -> None:
|
||||||
|
record = MediaRecord(
|
||||||
|
Path("20260607_140011.jpg"),
|
||||||
|
{"EXIF:ModifyDate": "2020:01:01 00:00:00"},
|
||||||
|
"image",
|
||||||
|
)
|
||||||
|
infer_missing_timestamps([record], True)
|
||||||
|
self.assertEqual(record.original_time, datetime(2026, 6, 7, 14, 0, 11))
|
||||||
|
self.assertEqual(record.inferred_timestamp_source, "filename")
|
||||||
|
|
||||||
|
def test_infers_missing_photo_time_from_modify_date(self) -> None:
|
||||||
|
record = MediaRecord(
|
||||||
|
Path("DSC0001.jpg"),
|
||||||
|
{"EXIF:ModifyDate": "2026:06:07 14:00:11"},
|
||||||
|
"image",
|
||||||
|
)
|
||||||
|
infer_missing_timestamps([record], True)
|
||||||
|
self.assertEqual(record.original_time, datetime(2026, 6, 7, 14, 0, 11))
|
||||||
|
self.assertEqual(record.inferred_timestamp_source, "ModifyDate")
|
||||||
|
|
||||||
|
def test_infers_video_timezone_from_filename_and_utc_end_time(self) -> None:
|
||||||
|
record = MediaRecord(
|
||||||
|
Path("20260704_161323.mp4"),
|
||||||
|
{},
|
||||||
|
"video",
|
||||||
|
original_time=datetime(2026, 7, 4, 14, 13, 36, tzinfo=timezone.utc),
|
||||||
|
duration_seconds=13,
|
||||||
|
)
|
||||||
|
self.assertEqual(infer_video_timezone(record), timezone(timedelta(hours=2)))
|
||||||
|
|
||||||
|
def test_existing_subseconds_prevent_burst_inference(self) -> None:
|
||||||
|
records = [
|
||||||
|
MediaRecord(Path("a.jpg"), {}, "image", adjusted_time=datetime(2026, 6, 7, 14, 0, 0), subsec=100),
|
||||||
|
MediaRecord(Path("b.jpg"), {}, "image", adjusted_time=datetime(2026, 6, 7, 14, 0, 0)),
|
||||||
|
]
|
||||||
|
infer_group_subseconds([records])
|
||||||
|
self.assertIsNone(records[1].inferred_subsec)
|
||||||
|
|
||||||
|
def test_burst_detection_stays_per_camera(self) -> None:
|
||||||
|
timestamp = datetime(2026, 6, 7, 14, 0, 0)
|
||||||
|
records = [
|
||||||
|
MediaRecord(Path("a.jpg"), {}, "image", adjusted_time=timestamp, camera_key="A"),
|
||||||
|
MediaRecord(Path("b.jpg"), {}, "image", adjusted_time=timestamp, camera_key="A"),
|
||||||
|
MediaRecord(Path("c.jpg"), {}, "image", adjusted_time=timestamp, camera_key="B"),
|
||||||
|
]
|
||||||
|
self.assertEqual([[record.path.name for record in group] for group in detect_photo_groups(records)], [["a.jpg", "b.jpg"]])
|
||||||
|
|
||||||
|
def test_single_file_pto_group_is_preserved(self) -> None:
|
||||||
|
record = MediaRecord(Path("a.jpg"), {}, "image")
|
||||||
|
self.assertEqual(merge_record_groups([record], [[record]]), [[record]])
|
||||||
|
|
||||||
|
def test_shift_keeps_later_modify_date_but_clamps_an_earlier_one(self) -> None:
|
||||||
|
original = datetime(2026, 6, 7, 14, 0, 0)
|
||||||
|
record = MediaRecord(
|
||||||
|
Path("photo.jpg"),
|
||||||
|
{
|
||||||
|
"EXIF:DateTimeOriginal": "2026:06:07 14:00:00",
|
||||||
|
"EXIF:CreateDate": "2026:06:07 14:00:00",
|
||||||
|
"EXIF:ModifyDate": "2026:06:07 13:00:00",
|
||||||
|
},
|
||||||
|
"image",
|
||||||
|
original_time=original,
|
||||||
|
adjusted_time=original + timedelta(hours=2),
|
||||||
|
)
|
||||||
|
writes = build_write_plan(record, self.choices(time_offset=timedelta(hours=2)))
|
||||||
|
changed = {write.label: write.new_value for write in writes if write.will_write}
|
||||||
|
self.assertEqual(changed["DateTimeOriginal"], "2026:06:07 16:00:00")
|
||||||
|
self.assertEqual(changed["CreateDate"], "2026:06:07 16:00:00")
|
||||||
|
self.assertEqual(changed["ModifyDate"], "2026:06:07 16:00:00")
|
||||||
|
self.assertTrue(any("ModifyDate" in warning for warning in record.warnings))
|
||||||
|
|
||||||
|
def test_timezone_only_follows_capture_related_dates(self) -> None:
|
||||||
|
captured = datetime(2026, 6, 7, 14, 0, 0)
|
||||||
|
record = MediaRecord(
|
||||||
|
Path("photo.jpg"),
|
||||||
|
{
|
||||||
|
"EXIF:DateTimeOriginal": "2026:06:07 14:00:00",
|
||||||
|
"EXIF:CreateDate": "2026:06:07 14:00:00",
|
||||||
|
"EXIF:ModifyDate": "2026:07:07 14:00:00",
|
||||||
|
"EXIF:OffsetTimeOriginal": "+00:00",
|
||||||
|
"EXIF:OffsetTimeDigitized": "+00:00",
|
||||||
|
"EXIF:OffsetTime": "+00:00",
|
||||||
|
},
|
||||||
|
"image",
|
||||||
|
original_time=captured,
|
||||||
|
adjusted_time=captured,
|
||||||
|
resolved_timezone=timezone(timedelta(hours=2)),
|
||||||
|
)
|
||||||
|
writes = build_write_plan(record, self.choices(fixed_timezone=record.resolved_timezone))
|
||||||
|
changed = {write.arg for write in writes if write.will_write}
|
||||||
|
self.assertIn("-EXIF:OffsetTimeOriginal=+02:00", changed)
|
||||||
|
self.assertIn("-EXIF:OffsetTimeDigitized=+02:00", changed)
|
||||||
|
self.assertNotIn("-EXIF:OffsetTime=+02:00", changed)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -87,7 +87,7 @@ from tools.video_probe import (
|
|||||||
)
|
)
|
||||||
from tools.video_reporting import _format_probe_timestamp, _format_vfr_timing
|
from tools.video_reporting import _format_probe_timestamp, _format_vfr_timing
|
||||||
from tools.video_reporting import _format_source_span, format_validation_summary_lines
|
from tools.video_reporting import _format_source_span, format_validation_summary_lines
|
||||||
from tools.console import PipelineProgressView, ProgressView, _progress_bar
|
from tools.console import PipelineProgressView, ProgressView, _progress_bar, stream_is_interactive
|
||||||
from tools.video_encode_output import _update_renderer_progress
|
from tools.video_encode_output import _update_renderer_progress
|
||||||
from tools.video_timeline import OutputFrameTiming, OutputTimeline, build_output_timeline
|
from tools.video_timeline import OutputFrameTiming, OutputTimeline, build_output_timeline
|
||||||
from video_encode import (
|
from video_encode import (
|
||||||
@@ -681,6 +681,72 @@ class VideoTimelineTests(unittest.TestCase):
|
|||||||
"[## 50.0% ###------------]",
|
"[## 50.0% ###------------]",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_progress_can_weight_percent_and_eta_by_work(self) -> None:
|
||||||
|
progress = ProgressView(4, "Applying changes", total_work=10, stream=StringIO())
|
||||||
|
|
||||||
|
line = progress.update_line(1, completed_work=5)
|
||||||
|
|
||||||
|
self.assertIn("1/4", line)
|
||||||
|
self.assertIn("50.0%", line)
|
||||||
|
|
||||||
|
def test_windows_console_progress_does_not_require_stdout_isatty(self) -> None:
|
||||||
|
class ConsoleBuffer(StringIO):
|
||||||
|
def isatty(self) -> bool:
|
||||||
|
return False
|
||||||
|
|
||||||
|
output = ConsoleBuffer()
|
||||||
|
with (
|
||||||
|
patch("tools.console.os.name", "nt"),
|
||||||
|
patch("tools.console.sys.stdout", output),
|
||||||
|
patch("tools.console._windows_console_output", return_value=True),
|
||||||
|
):
|
||||||
|
self.assertTrue(stream_is_interactive(output))
|
||||||
|
progress = ProgressView(3, "Applying changes", stream=output)
|
||||||
|
progress.update(1)
|
||||||
|
progress.update(2)
|
||||||
|
progress.finish(keep=True)
|
||||||
|
|
||||||
|
self.assertEqual(output.getvalue().count("\n"), 1)
|
||||||
|
|
||||||
|
def test_single_line_ansi_progress_never_moves_up_zero_lines(self) -> None:
|
||||||
|
class TtyBuffer(StringIO):
|
||||||
|
def isatty(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
output = TtyBuffer()
|
||||||
|
with patch("tools.console.supports_ansi", return_value=True):
|
||||||
|
progress = ProgressView(3, "Reading metadata", stream=output)
|
||||||
|
progress.update(1)
|
||||||
|
progress.update(2)
|
||||||
|
progress.finish(keep=True)
|
||||||
|
|
||||||
|
self.assertNotIn("\x1b[0F", output.getvalue())
|
||||||
|
|
||||||
|
def test_progress_redraws_a_wrapped_multi_line_block(self) -> None:
|
||||||
|
class TtyBuffer(StringIO):
|
||||||
|
def isatty(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
output = TtyBuffer()
|
||||||
|
with (
|
||||||
|
patch("tools.console.supports_ansi", return_value=True),
|
||||||
|
patch.dict("os.environ", {"COLUMNS": "30"}),
|
||||||
|
):
|
||||||
|
progress = ProgressView(3, "Progress", stream=output)
|
||||||
|
progress.write_lines(
|
||||||
|
[
|
||||||
|
"Applying changes",
|
||||||
|
" Results: succeeded 1 failed 0 metadata 1 no-op 0",
|
||||||
|
" File ops: rename 0 move 0 rename+move 0",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
progress.write_lines(["Applying changes", " Results: succeeded 2 failed 0 metadata 2 no-op 0"])
|
||||||
|
progress.finish(keep=True)
|
||||||
|
|
||||||
|
rendered = output.getvalue()
|
||||||
|
self.assertIn("\x1b[2K", rendered)
|
||||||
|
self.assertIn("\x1b[", rendered)
|
||||||
|
|
||||||
def test_encoding_progress_matches_persistent_validation_style(self) -> None:
|
def test_encoding_progress_matches_persistent_validation_style(self) -> None:
|
||||||
class TtyBuffer(StringIO):
|
class TtyBuffer(StringIO):
|
||||||
def isatty(self) -> bool:
|
def isatty(self) -> bool:
|
||||||
|
|||||||
Reference in New Issue
Block a user