diff --git a/README.md b/README.md index ed4cc22..49a5eb5 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ sudo apt install ffmpeg The default title/stamp styling uses these fonts: - Bebas Neue for the opening title, date, and location -- Inter for the bottom-left/bottom-right stamps and per-file captions +- Noto Sans SemiBold for the bottom-left/bottom-right stamps and per-file captions Both fonts support common Danish and Croatian characters such as `æ ø å Æ Ø Å č ć ž š đ Č Ć Ž Š Đ`. @@ -33,8 +33,8 @@ Install them locally for the current user: mkdir -p ~/.local/share/fonts/google curl -L -o ~/.local/share/fonts/google/BebasNeue-Regular.ttf \ 'https://github.com/google/fonts/raw/main/ofl/bebasneue/BebasNeue-Regular.ttf' -curl -L -o ~/.local/share/fonts/google/Inter%5Bopsz,wght%5D.ttf \ - 'https://github.com/google/fonts/raw/main/ofl/inter/Inter%5Bopsz,wght%5D.ttf' +curl -L -o ~/.local/share/fonts/google/NotoSans-SemiBold.ttf \ + 'https://github.com/google/fonts/raw/main/ofl/notosans/NotoSans%5Bwdth,wght%5D.ttf' fc-cache -f ~/.local/share/fonts/google ``` @@ -42,7 +42,7 @@ Check that fonts are available: ```bash fc-match 'Bebas Neue' -fc-match 'Inter' +fc-match 'Noto Sans' ``` ## Input folder @@ -98,6 +98,8 @@ If the date line is omitted, movmaker uses the date from the first picture/video May 14th 2026 ``` +If the location line is omitted and GPS coordinates are found in the first picture/video that has them, movmaker reverse geocodes them and uses that as the location. It tries to return only `city/town/village, country`; if no city/town/village is found, it uses just the country. + Key/value format also works: ```text @@ -154,7 +156,7 @@ python3 movmaker.py input --preview - fades music in over 2 seconds and out over 10 seconds by default - fades video in/out over 1.5 seconds by default - overlays the opening title for the first 6 seconds: large title, then date/location on one line -- displays optional per-file captions at bottom center; use `|` to split caption lines +- displays optional per-file captions at bottom center with a shadow; use `|` to split caption lines - adds persistent bottom-left title/date/place and bottom-right `@bubulescu` stamps - preserves special characters in rendered overlays and metadata - uses safe ASCII only for generated filenames, so special letters become readable equivalents like `Č` -> `C`, `å` -> `a`, `ø` -> `o` diff --git a/movmaker.py b/movmaker.py index 35e3c0f..9bfb15e 100755 --- a/movmaker.py +++ b/movmaker.py @@ -20,6 +20,8 @@ import subprocess import sys import tempfile import unicodedata +import urllib.parse +import urllib.request from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -117,12 +119,10 @@ def ffprobe_creation_date(path: Path) -> datetime | None: return None -def exif_image_date(path: Path) -> datetime | None: - """Read common JPEG/TIFF EXIF date tags using only the stdlib.""" +def image_tiff_data(path: Path) -> bytes | None: data = path.read_bytes()[:1024 * 1024] if data.startswith(b"\xff\xd8"): pos = 2 - tiff: bytes | None = None while pos + 4 <= len(data): if data[pos] != 0xFF: break @@ -136,12 +136,16 @@ def exif_image_date(path: Path) -> datetime | None: segment = data[pos + 2:pos + size] pos += size if marker == 0xE1 and segment.startswith(b"Exif\x00\x00"): - tiff = segment[6:] - break - if tiff is None: - return None - else: - tiff = data + return segment[6:] + return None + return data + + +def exif_image_date(path: Path) -> datetime | None: + """Read common JPEG/TIFF EXIF date tags using only the stdlib.""" + tiff = image_tiff_data(path) + if tiff is None: + return None if tiff[:2] == b"II": endian = "little" @@ -193,6 +197,163 @@ def exif_image_date(path: Path) -> datetime | None: return None +def exif_image_gps(path: Path) -> tuple[float, float] | None: + tiff = image_tiff_data(path) + if tiff is None: + return None + if tiff[:2] == b"II": + endian = "little" + elif tiff[:2] == b"MM": + endian = "big" + else: + return None + + def u16(off: int) -> int: + return int.from_bytes(tiff[off:off + 2], endian) + + def u32(off: int) -> int: + return int.from_bytes(tiff[off:off + 4], endian) + + def rational(off: int) -> float: + num = u32(off) + den = u32(off + 4) + return num / den if den else 0.0 + + def ascii_value(value: int, count: int, entry_off: int) -> str: + raw = tiff[entry_off + 8:entry_off + 8 + count] if count <= 4 else tiff[value:value + count] + return raw.split(b"\x00", 1)[0].decode("ascii", "ignore").strip() + + def rationals(value: int, count: int) -> list[float]: + return [rational(value + i * 8) for i in range(count)] + + try: + if u16(2) != 42: + return None + ifd0 = u32(4) + entries = u16(ifd0) + gps_ifd = None + for i in range(entries): + off = ifd0 + 2 + i * 12 + if off + 12 > len(tiff): + break + if u16(off) == 0x8825: + gps_ifd = u32(off + 8) + break + if gps_ifd is None or gps_ifd + 2 > len(tiff): + return None + gps_entries = u16(gps_ifd) + lat_ref = lon_ref = "" + lat = lon = None + for i in range(gps_entries): + off = gps_ifd + 2 + i * 12 + if off + 12 > len(tiff): + break + tag = u16(off) + typ = u16(off + 2) + count = u32(off + 4) + value = u32(off + 8) + if tag == 1 and typ == 2: + lat_ref = ascii_value(value, count, off) + elif tag == 2 and typ == 5: + lat = rationals(value, count) + elif tag == 3 and typ == 2: + lon_ref = ascii_value(value, count, off) + elif tag == 4 and typ == 5: + lon = rationals(value, count) + if not lat or not lon or len(lat) < 3 or len(lon) < 3: + return None + lat_dec = lat[0] + lat[1] / 60 + lat[2] / 3600 + lon_dec = lon[0] + lon[1] / 60 + lon[2] / 3600 + if lat_ref.upper() == "S": + lat_dec = -lat_dec + if lon_ref.upper() == "W": + lon_dec = -lon_dec + return lat_dec, lon_dec + except Exception: + return None + + +def ffprobe_gps(path: Path) -> tuple[float, float] | None: + try: + out = capture([ + tool_path(FFPROBE, "ffprobe"), "-v", "error", + "-show_format", "-show_streams", + "-of", "json", + str(path), + ]) + data = json.loads(out) + except Exception: + return None + + values: list[str] = [] + def collect(obj: object) -> None: + if isinstance(obj, dict): + tags = obj.get("tags") + if isinstance(tags, dict): + for key, value in tags.items(): + if "location" in key.lower() or "gps" in key.lower(): + values.append(str(value)) + for value in obj.values(): + collect(value) + elif isinstance(obj, list): + for value in obj: + collect(value) + collect(data) + for value in values: + match = re.search(r"([+-]\d+(?:\.\d+)?)([+-]\d+(?:\.\d+)?)", value) + if match: + return float(match.group(1)), float(match.group(2)) + return None + + +def media_gps(path: Path) -> tuple[float, float] | None: + if path.suffix.lower() in IMAGE_EXTS: + gps = exif_image_gps(path) + if gps: + return gps + return ffprobe_gps(path) + + +def reverse_geocode_city_country(lat: float, lon: float) -> str | None: + last_country = "" + for zoom in range(18, 2, -1): + query = urllib.parse.urlencode({ + "format": "jsonv2", + "lat": f"{lat:.8f}", + "lon": f"{lon:.8f}", + "zoom": str(zoom), + "addressdetails": "1", + }) + request = urllib.request.Request( + f"https://nominatim.openstreetmap.org/reverse?{query}", + headers={"User-Agent": "movmaker/1.0 (reverse geocoding for local movie metadata)"}, + ) + try: + with urllib.request.urlopen(request, timeout=10) as response: + data = json.loads(response.read().decode("utf-8")) + address = data.get("address", {}) + country = address.get("country", "") + last_country = country or last_country + city = next((address.get(key) for key in ("city", "town", "village") if address.get(key)), "") + if city: + return ", ".join(x for x in [city, country] if x) + except Exception: + continue + return last_country or None + + +def infer_place_from_gps(media: list[MediaItem]) -> str | None: + for item in media: + gps = media_gps(item.path) + if not gps: + continue + place = reverse_geocode_city_country(*gps) + if place: + print(f"Location from GPS: {place} ({gps[0]:.6f}, {gps[1]:.6f})") + return place + return None + + def media_date(path: Path) -> datetime: ext = path.suffix.lower() dt = None @@ -441,8 +602,8 @@ def build_video( final_label = "vout" title_label = "titled" title_font = str(Path.home() / ".local/share/fonts/google/BebasNeue-Regular.ttf") - stamp_font = str(Path.home() / ".local/share/fonts/google/Inter%5Bopsz,wght%5D.ttf") - stamp_bold_font = str(Path.home() / ".local/share/fonts/google/Inter%5Bopsz,wght%5D.ttf") + stamp_font = str(Path.home() / ".local/share/fonts/google/NotoSans-SemiBold.ttf") + stamp_bold_font = str(Path.home() / ".local/share/fonts/google/NotoSans-SemiBold.ttf") if title_lines: title_font_size = max(60, height // 10) subtitle_font_size = max(40, height // 18) @@ -600,6 +761,8 @@ def main(argv: list[str] | None = None) -> int: first_media_date = media_date(media[0].path) if not data.date: data.date = display_date(first_media_date) + if not data.place: + data.place = infer_place_from_gps(media) or "" fade = min(args.fade, max(0.0, args.image_duration - 0.1)) audio_fade = max(0.0, args.audio_fade) audio_fade_in = max(0.0, args.audio_fade_in)