From 53b2e5ecca3187e5b4911be633167457975a2e00 Mon Sep 17 00:00:00 2001 From: hbrain Date: Mon, 25 May 2026 09:43:51 +0000 Subject: [PATCH] Add Pixabay music fallback --- README.md | 42 ++++++++++++++++++++++--- movmaker.py | 91 ++++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 116 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 49a5eb5..e5a0dab 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 -- Noto Sans SemiBold for the bottom-left/bottom-right stamps and per-file captions +- Noto Sans Bold for the bottom-left/bottom-right stamps and per-file captions Both fonts support common Danish and Croatian characters such as `æ ø å Æ Ø Å č ć ž š đ Č Ć Ž Š Đ`. @@ -33,9 +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/NotoSans-SemiBold.ttf \ - 'https://github.com/google/fonts/raw/main/ofl/notosans/NotoSans%5Bwdth,wght%5D.ttf' -fc-cache -f ~/.local/share/fonts/google +sudo apt install fonts-noto-core +fc-cache -f ``` Check that fonts are available: @@ -45,6 +44,36 @@ fc-match 'Bebas Neue' fc-match 'Noto Sans' ``` +## Optional Pixabay music download + +If the input folder has no audio file, movmaker can download one random track from Pixabay Music. + +Save your Pixabay API key in either an environment variable: + +```bash +export PIXABAY_API_KEY='your_key_here' +``` + +or in a local config file: + +```bash +mkdir -p ~/.config/movmaker +echo 'PIXABAY_API_KEY=your_key_here' > ~/.config/movmaker/pixabay.env +chmod 600 ~/.config/movmaker/pixabay.env +``` + +The default music search query is: + +```text +cinematic punk rock +``` + +Override it with: + +```bash +python3 movmaker.py input --music-genre "upbeat rock" +``` + ## Input folder Example: @@ -134,6 +163,7 @@ python3 movmaker.py input \ --audio-fade 10 \ --audio-fade-in 2 \ --video-fade 1.5 \ + --music-genre "cinematic punk rock" \ --resolution 1920x1080 \ --fps 30 ``` @@ -153,10 +183,12 @@ python3 movmaker.py input --preview - includes video clips inline - crossfades between media items over 3 seconds by default - loops/trims music to match the video +- if no audio file exists in the input folder, searches Pixabay Music using `--music-genre` and downloads one random track; default query is `cinematic punk rock` +- reads the Pixabay API key from `PIXABAY_API_KEY` or `~/.config/movmaker/pixabay.env` - 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 shadow; use `|` to split caption lines +- displays optional per-file captions at bottom center with a thin black outline; 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 9bfb15e..b5737f0 100755 --- a/movmaker.py +++ b/movmaker.py @@ -14,6 +14,7 @@ import concurrent.futures import json import math import os +import random import re import shlex import subprocess @@ -33,6 +34,7 @@ DATA_NAMES = {"data.txt", "info.txt", "title.txt"} BASE_DIR = Path(__file__).resolve().parent FFMPEG = BASE_DIR / "ffmpeg" / "ffmpeg" FFPROBE = BASE_DIR / "ffmpeg" / "ffprobe" +PIXABAY_ENV = Path.home() / ".config" / "movmaker" / "pixabay.env" def tool_path(local_path: Path, fallback: str) -> str: @@ -516,6 +518,71 @@ def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int run(cmd) +def load_pixabay_api_key() -> str | None: + if os.environ.get("PIXABAY_API_KEY"): + return os.environ["PIXABAY_API_KEY"] + if PIXABAY_ENV.exists(): + for line in PIXABAY_ENV.read_text(encoding="utf-8", errors="replace").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, value = line.split("=", 1) + if key.strip() == "PIXABAY_API_KEY": + return value.strip().strip("'\"") + return None + + +def download_pixabay_music(genre: str, out_dir: Path) -> Path | None: + api_key = load_pixabay_api_key() + if not api_key: + print("No audio files found and no Pixabay API key configured; continuing without music.") + return None + query = urllib.parse.urlencode({ + "key": api_key, + "q": genre, + "media_type": "music", + "safesearch": "true", + "per_page": "50", + }) + request = urllib.request.Request( + f"https://pixabay.com/api/audio/?{query}", + headers={"User-Agent": "movmaker/1.0"}, + ) + try: + with urllib.request.urlopen(request, timeout=20) as response: + data = json.loads(response.read().decode("utf-8")) + except Exception as exc: + print(f"Could not search Pixabay music: {exc}") + return None + + hits = [hit for hit in data.get("hits", []) if hit.get("audio")] + if not hits: + print(f"No Pixabay music found for genre/query: {genre}") + return None + hit = random.choice(hits) + audio_url = hit["audio"] + title = hit.get("title") or "pixabay_music" + filename = f"pixabay_{hit.get('id', 'music')}_{slugify(title)}.mp3" + out_dir.mkdir(parents=True, exist_ok=True) + out = out_dir / filename + print(f"Downloading Pixabay music: {title}") + try: + urllib.request.urlretrieve(audio_url, out) + except Exception as exc: + print(f"Could not download Pixabay music: {exc}") + return None + attribution = out.with_suffix(".txt") + attribution.write_text( + "Pixabay music downloaded by movmaker\n" + f"Title: {title}\n" + f"Artist/User: {hit.get('user', '')}\n" + f"URL: {hit.get('pageURL', '')}\n" + "Source: Pixabay\n", + encoding="utf-8", + ) + return out + + def concat_or_mix_audio(audio_files: list[Path], out: Path) -> Path | None: if not audio_files: return None @@ -602,8 +669,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/NotoSans-SemiBold.ttf") - stamp_bold_font = str(Path.home() / ".local/share/fonts/google/NotoSans-SemiBold.ttf") + stamp_font = "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf" + stamp_bold_font = "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf" if title_lines: title_font_size = max(60, height // 10) subtitle_font_size = max(40, height // 18) @@ -642,7 +709,7 @@ def build_video( filters.append(f"[{current}]copy[{title_label}]") caption_label = title_label - caption_size = max(16, int((subtitle_font_size if title_lines else max(40, height // 18)) * 0.5)) + caption_size = max(24, int((subtitle_font_size if title_lines else max(40, height // 18)) * 0.7)) for idx, caption in enumerate(captions): if not caption: continue @@ -673,18 +740,12 @@ def build_video( next_label = f"caption{idx}_{line_idx}" filters.append( f"[{caption_label}]drawtext=fontfile='{stamp_bold_font}':{textfile_arg(caption_line)}:x=(w-text_w)/2:y={y_expr}:" - f"fontsize={caption_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=2:shadowy=2:" + f"fontsize={caption_size}:fontcolor=white:borderw=1:bordercolor=black:" f"alpha='{caption_alpha}':enable='between(t,{start:.3f},{end:.3f})'[{next_label}]" ) - bold_label = f"caption{idx}_{line_idx}b" - filters.append( - f"[{next_label}]drawtext=fontfile='{stamp_bold_font}':{textfile_arg(caption_line)}:x=(w-text_w)/2+1:y={y_expr}:" - f"fontsize={caption_size}:fontcolor=white:borderw=0:shadowcolor=black@0.75:shadowx=2:shadowy=2:" - f"alpha='{caption_alpha}':enable='between(t,{start:.3f},{end:.3f})'[{bold_label}]" - ) - caption_label = bold_label + caption_label = next_label - stamp_size = max(14, height // 65) + stamp_size = max(16, height // 58) stamp_lines = [x for x in [data.title, data.date, data.place] if x] stamp_text = drawtext_escape(" | ".join(stamp_lines)) stamp_left_label = "stamp_left" @@ -742,6 +803,7 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument("--crf", type=int, default=20, help="x264 quality; lower is better/larger, higher is faster/smaller") parser.add_argument("--jobs", type=int, default=1, help="parallel clip preparation jobs; try 2 on Raspberry Pi") parser.add_argument("--preview", action="store_true", help="fast preview: 1280x720, 24 fps, ultrafast, CRF 28") + parser.add_argument("--music-genre", default="cinematic punk rock", help="Pixabay music search query used only when input has no audio files") args = parser.parse_args(argv) require_tool("ffmpeg") @@ -768,6 +830,11 @@ def main(argv: list[str] | None = None) -> int: audio_fade_in = max(0.0, args.audio_fade_in) video_fade = max(0.0, args.video_fade) + if not audio_files: + downloaded_audio = download_pixabay_music(args.music_genre, args.input) + if downloaded_audio: + audio_files.append(downloaded_audio) + output = default_output_path(media[0].path, data, args.out_dir) # Image duration means fully visible time, not including crossfades.