From 8a0901590bd1c1ea1dae0f4c54388543d609b105 Mon Sep 17 00:00:00 2001 From: hbrain Date: Mon, 25 May 2026 11:51:09 +0000 Subject: [PATCH] Minor updates --- README.md | 10 ++++++---- movmaker.py | 46 ++++++++++++++++++++++++++++++---------------- 2 files changed, 36 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 38f7138..ea364cb 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,8 @@ sudo apt install ffmpeg The default title/stamp styling uses these fonts: - Bebas Neue for the opening title, date, and location -- Noto Sans Bold for the bottom-left/bottom-right stamps and per-file captions +- Noto Sans Bold for the bottom-left/bottom-right stamps +- IBM Plex Sans SemiBold for per-file captions Both fonts support common Danish and Croatian characters such as `æ ø å Æ Ø Å č ć ž š đ Č Ć Ž Š Đ`. @@ -33,7 +34,7 @@ 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' -sudo apt install fonts-noto-core +sudo apt install fonts-noto-core fonts-ibm-plex fc-cache -f ``` @@ -42,6 +43,7 @@ Check that fonts are available: ```bash fc-match 'Bebas Neue' fc-match 'Noto Sans' +fc-match 'IBM Plex Sans' ``` ## Optional Jamendo music download @@ -124,7 +126,7 @@ Those three lines mean: If the date line is omitted, movmaker uses the date from the first picture/video and displays it like: ```text -May 14th 2026 +May 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. @@ -188,7 +190,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 with a thin black outline; use `|` to split caption lines +- displays optional per-file captions at bottom center with slightly off-white text and a soft shadow; use `|` to split caption lines - adds persistent bottom-left title/date/place and bottom-right `Bubulescu.Org` 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 2f4e1d9..4820af2 100755 --- a/movmaker.py +++ b/movmaker.py @@ -466,7 +466,7 @@ def ordinal_day(day: int) -> str: def display_date(dt: datetime) -> str: - return f"{dt.strftime('%B')} {ordinal_day(dt.day)} {dt.year}" + return f"{dt.strftime('%B')} {dt.year}" def timestamp() -> str: @@ -477,14 +477,6 @@ def console(message: str) -> None: print(f"[{timestamp()}] {ascii_safe(message)}") -def estimate_output_size(duration: float, width: int, height: int, fps: int, crf: int) -> int: - pixels = width * height - quality_factor = max(0.45, (28 - crf) / 8) - bitrate_mbps = max(2.0, pixels * fps * quality_factor / 7_000_000) - audio_mbps = 0.192 - return int(duration * (bitrate_mbps + audio_mbps) * 1_000_000 / 8) - - def default_output_path(first_media: Path, data: MovieData, out_dir: Path) -> Path: date = media_date(first_media).strftime("%Y%m%d") title = slugify(data.title or first_media.parent.name or "movie") @@ -601,6 +593,22 @@ def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int run(cmd) +def audio_metadata(path: Path) -> tuple[str, str]: + try: + out = capture([ + tool_path(FFPROBE, "ffprobe"), "-v", "error", + "-show_entries", "format_tags=artist,title", + "-of", "json", + str(path), + ]) + tags = json.loads(out).get("format", {}).get("tags", {}) + artist = tags.get("artist") or tags.get("ARTIST") or "" + title = tags.get("title") or tags.get("TITLE") or "" + return str(artist), str(title) + except Exception: + return "", "" + + def load_jamendo_client_id() -> str | None: if os.environ.get("JAMENDO_CLIENT_ID"): return os.environ["JAMENDO_CLIENT_ID"] @@ -667,6 +675,8 @@ def download_jamendo_music(genre: str, out_dir: Path) -> Path | None: except Exception as exc: console(f"Could not download Jamendo music: {exc}") return None + if title or artist: + console("Audio metadata: " + " - ".join(x for x in [artist, title] if x)) log_dir = out_dir / ".logs" log_dir.mkdir(parents=True, exist_ok=True) attribution = log_dir / f"{out.stem}.txt" @@ -770,7 +780,7 @@ def build_video( title_label = "titled" title_font = str(Path.home() / ".local/share/fonts/google/BebasNeue-Regular.ttf") stamp_font = "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf" - caption_font = "Montserrat\\:style=Bold" + caption_font = "/usr/share/fonts/truetype/ibm-plex/IBMPlexSans-SemiBold.ttf" if title_lines: title_font_size = max(60, height // 10) subtitle_font_size = max(40, height // 18) @@ -839,8 +849,8 @@ def build_video( y_expr = f"h-text_h-62-{block_height - line_idx * line_gap}" next_label = f"caption{idx}_{line_idx}" filters.append( - f"[{caption_label}]drawtext=font='{caption_font}':{textfile_arg(caption_line)}:x=(w-text_w)/2:y={y_expr}:" - f"fontsize={caption_size}:fontcolor=white:borderw=1:bordercolor=black:" + f"[{caption_label}]drawtext=fontfile='{caption_font}':{textfile_arg(caption_line)}:x=(w-text_w)/2:y={y_expr}:" + f"fontsize={caption_size}:fontcolor=0xF2F2EE:borderw=0:shadowcolor=black@0.80:shadowx=3:shadowy=3:" f"alpha='{caption_alpha}':enable='between(t,{start:.3f},{end:.3f})'[{next_label}]" ) caption_label = next_label @@ -958,6 +968,10 @@ def main(argv: list[str] | None = None) -> int: console(f"Found {len(media)} media file(s), {len(audio_files)} audio file(s).") console(f"Audio: {audio_files[0] if audio_files else 'none'}") + if audio_files: + artist, title = audio_metadata(audio_files[0]) + if artist or title: + console("Audio metadata: " + " - ".join(x for x in [artist, title] if x)) console(f"Output: {output}") console(f"Log: {LOG_PATH}") if data.title or data.date or data.place: @@ -989,14 +1003,14 @@ def main(argv: list[str] | None = None) -> int: # Use probed normalized duration for accuracy. durations = [ffprobe_duration(out) for out in clips] - estimated_duration = durations[0] if len(durations) == 1 else durations[0] + sum(durations[1:]) - fade * (len(durations) - 1) - estimated_size = estimate_output_size(estimated_duration, width, height, fps, crf) - captions = [data.captions.get(item.path.name, data.captions.get(str(item.path), "")) for item in media] audio = concat_or_mix_audio(audio_files, tmp / "music.m4a") if len(audio_files) > 1: console(f"Audio: {audio if audio else 'none'}") - console(f"Estimated output: video length {estimated_duration:.1f}s, size about {format_bytes(estimated_size)}") + if audio: + artist, title = audio_metadata(audio) + if artist or title: + console("Audio metadata: " + " - ".join(x for x in [artist, title] if x)) build_video(clips, durations, audio, output, data, first_media_date, captions, fade, audio_fade, audio_fade_in, video_fade, width, height, fps, preset, crf, tmp) console(f"Done: {output}")