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:
|
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`
|
||||||
|
|
|
||||||
91
movmaker.py
91
movmaker.py
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue