Add Pixabay music fallback
This commit is contained in:
parent
e35201496f
commit
53b2e5ecca
2 changed files with 116 additions and 17 deletions
42
README.md
42
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`
|
||||
|
|
|
|||
91
movmaker.py
91
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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue