Refactor photo metadata workflow

This commit is contained in:
ajp_anton
2026-08-24 16:34:20 +00:00
parent 100cad1cb5
commit eb5937a629
15 changed files with 2111 additions and 1727 deletions
+179 -30
View File
@@ -1,14 +1,19 @@
from __future__ import annotations
import os
import select
import shutil
import sys
import textwrap
import time
from collections import deque
from contextlib import contextmanager
from math import floor, log10
from threading import Lock
_WINDOWS_ANSI_ENABLED: bool | None = None
_WINDOWS_CONSOLE_OUTPUT: bool | None = None
def prompt_input(prompt: str) -> str:
@@ -44,7 +49,7 @@ def supports_color() -> bool:
def supports_ansi() -> bool:
if not sys.stdout.isatty():
if not stream_is_interactive(sys.stdout):
return False
if os.name != "nt":
return os.environ.get("TERM", "dumb") != "dumb"
@@ -56,6 +61,10 @@ def supports_ansi() -> bool:
)
def stream_is_interactive(stream) -> bool:
return stream.isatty() or (os.name == "nt" and stream is sys.stdout and _windows_console_output())
def color(text: object, code: str) -> str:
value = str(text)
if not supports_color():
@@ -79,12 +88,74 @@ def red_strikethrough(text: object) -> str:
return color(text, "91;9")
def clear_screen() -> None:
if not sys.stdout.isatty():
def clear_screen(*, scrollback: bool = False) -> None:
if not stream_is_interactive(sys.stdout):
return
if supports_ansi():
sys.stdout.write("\x1b[3J" if scrollback else "")
sys.stdout.write("\x1b[H\x1b[2J")
sys.stdout.flush()
return
os.system("cls" if os.name == "nt" else "clear")
def require_terminal_ui() -> None:
if not (stream_is_interactive(sys.stdin) and stream_is_interactive(sys.stdout) and supports_ansi()):
raise RuntimeError("This settings dialog requires an ANSI-capable interactive terminal.")
@contextmanager
def raw_key_input():
require_terminal_ui()
if os.name == "nt":
yield read_key
return
import termios
import tty
fd = sys.stdin.fileno()
settings = termios.tcgetattr(fd)
try:
tty.setraw(fd)
yield read_key
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, settings)
def read_key() -> str:
if os.name == "nt":
import msvcrt
key = msvcrt.getwch()
if key in {"\x00", "\xe0"}:
return {"H": "up", "P": "down", "K": "left", "M": "right"}.get(msvcrt.getwch(), "")
else:
key = sys.stdin.read(1)
if key == "\x1b" and select.select([sys.stdin], [], [], 0.03)[0]:
key += sys.stdin.read(2)
return {"\x1b[A": "up", "\x1b[B": "down", "\x1b[D": "left", "\x1b[C": "right"}.get(key, "esc")
return {"\r": "enter", "\n": "enter", "\x1b": "esc", "\x08": "backspace", "\x7f": "backspace", " ": "space"}.get(key, key)
def draw_screen(lines: list[str]) -> None:
require_terminal_ui()
sys.stdout.write("\x1b[H\x1b[2J" + "\n".join(lines))
sys.stdout.flush()
def inverse(text: str) -> str:
return f"\x1b[30;47m{text}\x1b[0m"
def dim(text: str) -> str:
return f"\x1b[90m{text}\x1b[0m"
def dark_field(text: str) -> str:
return f"\x1b[30;100m{text}\x1b[0m"
def _enable_windows_ansi() -> bool:
global _WINDOWS_ANSI_ENABLED
if _WINDOWS_ANSI_ENABLED is not None:
@@ -105,6 +176,23 @@ def _enable_windows_ansi() -> bool:
return False
def _windows_console_output() -> bool:
global _WINDOWS_CONSOLE_OUTPUT
if _WINDOWS_CONSOLE_OUTPUT is not None:
return _WINDOWS_CONSOLE_OUTPUT
try:
import ctypes
mode = ctypes.c_uint32()
handle = ctypes.windll.kernel32.GetStdHandle(-11)
_WINDOWS_CONSOLE_OUTPUT = bool(
ctypes.windll.kernel32.GetConsoleMode(handle, ctypes.byref(mode))
)
except Exception:
_WINDOWS_CONSOLE_OUTPUT = False
return _WINDOWS_CONSOLE_OUTPUT
def format_duration(seconds: float) -> str:
seconds = max(0, int(round(seconds)))
minutes, second = divmod(seconds, 60)
@@ -120,30 +208,42 @@ class ProgressView:
total: int,
label: str,
*,
total_work: float | None = None,
stream=None,
embedded_percent: bool = False,
show_rate: bool = False,
show_elapsed: bool = True,
bar_width: int = 24,
) -> None:
self.total = max(0, total)
self.total_work = total if total_work is None else max(0.0, total_work)
self.label = label
self.stream = stream or sys.stdout
self.started_at = time.monotonic()
self.samples: deque[tuple[int, float]] = deque()
self.samples: deque[tuple[float, float]] = deque()
self.last_width = 0
self.last_line_count = 0
self.embedded_percent = embedded_percent
self.show_rate = show_rate
self.show_elapsed = show_elapsed
self.bar_width = bar_width
def update(self, processed: int, detail: str = "") -> None:
self._write(self.update_line(processed, detail))
def update(
self, processed: int, detail: str = "", *, completed_work: float | None = None
) -> None:
self._write(self.update_line(processed, detail, completed_work=completed_work))
def update_line(self, processed: int, detail: str = "") -> str:
def update_line(
self, processed: int, detail: str = "", *, completed_work: float | None = None
) -> str:
processed = max(0, min(processed, self.total))
work_done = min(self.total_work, completed_work if completed_work is not None else processed)
now = time.monotonic()
if not self.samples or self.samples[-1][0] != processed:
self.samples.append((processed, now))
if not self.samples or self.samples[-1][0] != work_done:
self.samples.append((work_done, now))
while len(self.samples) > max(2, processed // 2 + 2):
self.samples.popleft()
return self._line(processed, detail, now)
return self._line(processed, detail, now, completed_work=work_done)
def update_external(
self,
@@ -167,25 +267,60 @@ class ProgressView:
return self._line(processed, detail, None, elapsed=elapsed, eta=eta)
def finish(self, *, keep: bool = False) -> None:
if self.stream.isatty():
if stream_is_interactive(self.stream):
if keep:
self.stream.write("\n")
else:
self.stream.write("\r" + " " * self.last_width + "\r")
self._clear_live_lines()
self.stream.flush()
elif self.last_width and not keep:
self.stream.write("\n")
self.last_width = 0
self.last_line_count = 0
def _write(self, line: str) -> None:
if self.stream.isatty():
def write_lines(self, lines: list[str]) -> None:
self._write(lines)
def _write(self, content: str | list[str]) -> None:
lines = [content] if isinstance(content, str) else content
if stream_is_interactive(self.stream) and supports_ansi():
lines = [wrapped for line in lines for wrapped in _wrap_to_terminal(line, self.stream)]
if self.last_line_count > 1:
self.stream.write(f"\x1b[{self.last_line_count - 1}F")
rows = max(self.last_line_count, len(lines))
for index in range(rows):
self.stream.write("\r\x1b[2K")
if index < len(lines):
self.stream.write(lines[index])
if index < rows - 1:
self.stream.write("\n")
if rows > len(lines):
self.stream.write(f"\x1b[{rows - len(lines)}F")
self.stream.flush()
self.last_width = max(map(len, lines), default=0)
self.last_line_count = len(lines)
elif stream_is_interactive(self.stream):
line = " ".join(lines)
padding = max(0, self.last_width - len(line))
self.stream.write("\r" + line + (" " * padding))
self.stream.flush()
self.last_width = len(line)
else:
self.stream.write(line + "\n")
self.last_width = len(line)
self.stream.write("\n".join(lines) + "\n")
self.last_width = max(map(len, lines), default=0)
def _clear_live_lines(self) -> None:
if not self.last_line_count or not supports_ansi():
self.stream.write("\r" + " " * self.last_width + "\r")
return
if self.last_line_count > 1:
self.stream.write(f"\x1b[{self.last_line_count - 1}F")
for index in range(self.last_line_count):
self.stream.write("\r\x1b[2K")
if index < self.last_line_count - 1:
self.stream.write("\n")
if self.last_line_count > 1:
self.stream.write(f"\x1b[{self.last_line_count - 1}F")
def _line(
self,
@@ -195,13 +330,15 @@ class ProgressView:
*,
elapsed: float | None = None,
eta: float | None = None,
completed_work: float | None = None,
) -> str:
prefix = self.label if not detail else f"{self.label}: {detail}"
prefix = self.label
if self.total <= 2:
return f"{prefix} {processed}/{self.total}"
return " ".join(part for part in (prefix, f"{processed}/{self.total}", detail) if part)
percent = 0.0 if self.total == 0 else processed / self.total
bar = _progress_bar(percent, embedded_percent=self.embedded_percent)
work_done = processed if completed_work is None else completed_work
percent = 0.0 if self.total_work == 0 else work_done / self.total_work
bar = _progress_bar(percent, embedded_percent=self.embedded_percent, width=self.bar_width)
elapsed_value = elapsed if elapsed is not None else (now or time.monotonic()) - self.started_at
parts = [
prefix,
@@ -212,22 +349,25 @@ class ProgressView:
parts.append(f"{percent * 100:5.1f}%")
if self.show_rate and processed > 0 and elapsed_value > 0:
parts.append(f"{_format_significant(processed / elapsed_value, 2)} fps")
parts.append(f"elapsed {format_duration(elapsed_value)}")
eta_value = eta if eta is not None else self._eta(processed)
if self.show_elapsed:
parts.append(f"elapsed {format_duration(elapsed_value)}")
eta_value = eta if eta is not None else self._eta(processed, work_done)
if processed >= 2 and processed < self.total and eta_value is not None:
parts.append(f"ETA {format_duration(eta_value)}")
if detail:
parts.append(detail)
return " ".join(parts)
def _eta(self, processed: int) -> float | None:
def _eta(self, processed: int, work_done: float) -> float | None:
if processed < 2 or self.total <= processed or len(self.samples) < 2:
return None
oldest_processed, oldest_time = self.samples[0]
newest_processed, newest_time = self.samples[-1]
delta_items = newest_processed - oldest_processed
oldest_work, oldest_time = self.samples[0]
newest_work, newest_time = self.samples[-1]
delta_work = newest_work - oldest_work
delta_time = newest_time - oldest_time
if delta_items <= 0 or delta_time <= 0:
if delta_work <= 0 or delta_time <= 0:
return None
return (self.total - processed) * (delta_time / delta_items)
return (self.total_work - work_done) * (delta_time / delta_work)
class PipelineProgressView:
@@ -311,8 +451,7 @@ class PipelineProgressView:
self._shown = True
def _progress_bar(percent: float, *, embedded_percent: bool) -> str:
width = 24
def _progress_bar(percent: float, *, embedded_percent: bool, width: int = 24) -> str:
done = round(max(0.0, min(percent, 1.0)) * width)
if not embedded_percent:
return "[" + ("#" * done).ljust(width, "-") + "]"
@@ -335,6 +474,16 @@ def _center_progress_text(text: str, width: int, fill: str) -> str:
return content.center(width, fill)
def _wrap_to_terminal(line: str, stream) -> list[str]:
try:
width = shutil.get_terminal_size().columns
except OSError:
return [line]
if width <= 0 or len(line) <= width:
return [line]
return textwrap.wrap(line, width=width, break_long_words=False, break_on_hyphens=False) or [line]
def _format_significant(value: float, digits: int) -> str:
if value == 0:
return "0"