Add Pixabay music fallback

This commit is contained in:
hbrain 2026-05-25 09:43:51 +00:00
parent e35201496f
commit 53b2e5ecca
2 changed files with 116 additions and 17 deletions

View file

@ -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`

View file

@ -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.