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: The default title/stamp styling uses these fonts:
- Bebas Neue for the opening title, date, and location - 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 `æ ø å Æ Ø Å č ć ž š đ Č Ć Ž Š Đ`. 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 mkdir -p ~/.local/share/fonts/google
curl -L -o ~/.local/share/fonts/google/BebasNeue-Regular.ttf \ curl -L -o ~/.local/share/fonts/google/BebasNeue-Regular.ttf \
'https://github.com/google/fonts/raw/main/ofl/bebasneue/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 \ sudo apt install fonts-noto-core
'https://github.com/google/fonts/raw/main/ofl/notosans/NotoSans%5Bwdth,wght%5D.ttf' fc-cache -f
fc-cache -f ~/.local/share/fonts/google
``` ```
Check that fonts are available: Check that fonts are available:
@ -45,6 +44,36 @@ fc-match 'Bebas Neue'
fc-match 'Noto Sans' 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 ## Input folder
Example: Example:
@ -134,6 +163,7 @@ python3 movmaker.py input \
--audio-fade 10 \ --audio-fade 10 \
--audio-fade-in 2 \ --audio-fade-in 2 \
--video-fade 1.5 \ --video-fade 1.5 \
--music-genre "cinematic punk rock" \
--resolution 1920x1080 \ --resolution 1920x1080 \
--fps 30 --fps 30
``` ```
@ -153,10 +183,12 @@ python3 movmaker.py input --preview
- includes video clips inline - includes video clips inline
- crossfades between media items over 3 seconds by default - crossfades between media items over 3 seconds by default
- loops/trims music to match the video - 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 music in over 2 seconds and out over 10 seconds by default
- fades video in/out over 1.5 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 - 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 - adds persistent bottom-left title/date/place and bottom-right `@bubulescu` stamps
- preserves special characters in rendered overlays and metadata - 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` - 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 json
import math import math
import os import os
import random
import re import re
import shlex import shlex
import subprocess import subprocess
@ -33,6 +34,7 @@ DATA_NAMES = {"data.txt", "info.txt", "title.txt"}
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
FFMPEG = BASE_DIR / "ffmpeg" / "ffmpeg" FFMPEG = BASE_DIR / "ffmpeg" / "ffmpeg"
FFPROBE = BASE_DIR / "ffmpeg" / "ffprobe" FFPROBE = BASE_DIR / "ffmpeg" / "ffprobe"
PIXABAY_ENV = Path.home() / ".config" / "movmaker" / "pixabay.env"
def tool_path(local_path: Path, fallback: str) -> str: 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) 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: def concat_or_mix_audio(audio_files: list[Path], out: Path) -> Path | None:
if not audio_files: if not audio_files:
return None return None
@ -602,8 +669,8 @@ def build_video(
final_label = "vout" final_label = "vout"
title_label = "titled" title_label = "titled"
title_font = str(Path.home() / ".local/share/fonts/google/BebasNeue-Regular.ttf") 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_font = "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf"
stamp_bold_font = str(Path.home() / ".local/share/fonts/google/NotoSans-SemiBold.ttf") stamp_bold_font = "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf"
if title_lines: if title_lines:
title_font_size = max(60, height // 10) title_font_size = max(60, height // 10)
subtitle_font_size = max(40, height // 18) subtitle_font_size = max(40, height // 18)
@ -642,7 +709,7 @@ def build_video(
filters.append(f"[{current}]copy[{title_label}]") filters.append(f"[{current}]copy[{title_label}]")
caption_label = 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): for idx, caption in enumerate(captions):
if not caption: if not caption:
continue continue
@ -673,18 +740,12 @@ def build_video(
next_label = f"caption{idx}_{line_idx}" next_label = f"caption{idx}_{line_idx}"
filters.append( filters.append(
f"[{caption_label}]drawtext=fontfile='{stamp_bold_font}':{textfile_arg(caption_line)}:x=(w-text_w)/2:y={y_expr}:" 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}]" f"alpha='{caption_alpha}':enable='between(t,{start:.3f},{end:.3f})'[{next_label}]"
) )
bold_label = f"caption{idx}_{line_idx}b" caption_label = next_label
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
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_lines = [x for x in [data.title, data.date, data.place] if x]
stamp_text = drawtext_escape(" | ".join(stamp_lines)) stamp_text = drawtext_escape(" | ".join(stamp_lines))
stamp_left_label = "stamp_left" 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("--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("--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("--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) args = parser.parse_args(argv)
require_tool("ffmpeg") 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) audio_fade_in = max(0.0, args.audio_fade_in)
video_fade = max(0.0, args.video_fade) 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) output = default_output_path(media[0].path, data, args.out_dir)
# Image duration means fully visible time, not including crossfades. # Image duration means fully visible time, not including crossfades.