Refine logging and Jamendo docs

This commit is contained in:
hbrain 2026-05-25 10:19:45 +00:00
parent 55ee91cae2
commit c42e44231a
3 changed files with 76 additions and 55 deletions

1
.gitignore vendored
View file

@ -5,6 +5,7 @@ __pycache__/
venv/ venv/
# Generated videos / outputs # Generated videos / outputs
.logs/
movmaker.log movmaker.log
out/ out/
*.mp4 *.mp4

View file

@ -44,22 +44,22 @@ fc-match 'Bebas Neue'
fc-match 'Noto Sans' fc-match 'Noto Sans'
``` ```
## Optional Pixabay music download ## Optional Jamendo music download
If the input folder has no audio file, movmaker can download one random track from Pixabay Music. If the input folder has no audio file, movmaker can download one random track from Jamendo.
Save your Pixabay API key in either an environment variable: Save your Jamendo client ID in either an environment variable:
```bash ```bash
export PIXABAY_API_KEY='your_key_here' export JAMENDO_CLIENT_ID='your_client_id_here'
``` ```
or in a local config file: or in a local config file:
```bash ```bash
mkdir -p ~/.config/movmaker mkdir -p ~/.config/movmaker
echo 'PIXABAY_API_KEY=your_key_here' > ~/.config/movmaker/pixabay.env echo 'JAMENDO_CLIENT_ID=your_client_id_here' > ~/.config/movmaker/jamendo.env
chmod 600 ~/.config/movmaker/pixabay.env chmod 600 ~/.config/movmaker/jamendo.env
``` ```
The default music search query is: The default music search query is:
@ -183,8 +183,8 @@ 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` - if no audio file exists in the input folder, searches Jamendo 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` - reads the Jamendo client ID from `JAMENDO_CLIENT_ID` or `~/.config/movmaker/jamendo.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
@ -195,7 +195,7 @@ python3 movmaker.py input --preview
- embeds MP4 metadata: title, date, location/place, QuickTime location name, artist, comment, and creation_time - embeds MP4 metadata: title, date, location/place, QuickTime location name, artist, comment, and creation_time
- writes an MP4 file named like `YYYYMMDD_title.mp4` in the current directory by default; use `--out-dir DIR` to choose another output directory - writes an MP4 file named like `YYYYMMDD_title.mp4` in the current directory by default; use `--out-dir DIR` to choose another output directory
- takes `YYYYMMDD` from the first picture/video metadata, falling back to file modification date - takes `YYYYMMDD` from the first picture/video metadata, falling back to file modification date
- keeps console output minimal during rendering and writes detailed ffmpeg output to `movmaker.log` in the input directory - keeps console output minimal during rendering and writes detailed ffmpeg output to timestamped logs in `.logs/` inside the input directory
- uses local `ffmpeg/ffmpeg` and `ffmpeg/ffprobe` when present, otherwise system tools - uses local `ffmpeg/ffmpeg` and `ffmpeg/ffprobe` when present, otherwise system tools
## Notes ## Notes

View file

@ -34,7 +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" JAMENDO_ENV = Path.home() / ".config" / "movmaker" / "jamendo.env"
LOG_PATH: Path | None = None LOG_PATH: Path | None = None
@ -120,7 +120,7 @@ def run(cmd: list[str], progress_duration: float | None = None) -> None:
percent = min(100.0, max(0.0, seconds / progress_duration * 100.0)) percent = min(100.0, max(0.0, seconds / progress_duration * 100.0))
fps = fields.get("fps", "?") fps = fields.get("fps", "?")
speed = fields.get("speed", "?") speed = fields.get("speed", "?")
sys.stdout.write(f"\rRendering {percent:5.1f}% fps={fps} speed={speed} written={format_bytes(written)}") sys.stdout.write(f"\r[{timestamp()}] Rendering {percent:5.1f}% fps={fps} speed={speed} written={format_bytes(written)}")
sys.stdout.flush() sys.stdout.flush()
rc = process.wait() rc = process.wait()
sys.stdout.write("\n") sys.stdout.write("\n")
@ -418,7 +418,7 @@ def infer_place_from_gps(media: list[MediaItem]) -> str | None:
continue continue
place = reverse_geocode_city_country(*gps) place = reverse_geocode_city_country(*gps)
if place: if place:
print(f"Location from GPS: {place} ({gps[0]:.6f}, {gps[1]:.6f})") console(f"Location from GPS: {place} ({gps[0]:.6f}, {gps[1]:.6f})")
return place return place
return None return None
@ -469,6 +469,14 @@ def display_date(dt: datetime) -> str:
return f"{dt.strftime('%B')} {ordinal_day(dt.day)} {dt.year}" return f"{dt.strftime('%B')} {ordinal_day(dt.day)} {dt.year}"
def timestamp() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def console(message: str) -> None:
print(f"[{timestamp()}] {ascii_safe(message)}")
def default_output_path(first_media: Path, data: MovieData, out_dir: Path) -> Path: def default_output_path(first_media: Path, data: MovieData, out_dir: Path) -> Path:
date = media_date(first_media).strftime("%Y%m%d") date = media_date(first_media).strftime("%Y%m%d")
title = slugify(data.title or first_media.parent.name or "movie") title = slugify(data.title or first_media.parent.name or "movie")
@ -585,66 +593,70 @@ def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int
run(cmd) run(cmd)
def load_pixabay_api_key() -> str | None: def load_jamendo_client_id() -> str | None:
if os.environ.get("PIXABAY_API_KEY"): if os.environ.get("JAMENDO_CLIENT_ID"):
return os.environ["PIXABAY_API_KEY"] return os.environ["JAMENDO_CLIENT_ID"]
if PIXABAY_ENV.exists(): if JAMENDO_ENV.exists():
for line in PIXABAY_ENV.read_text(encoding="utf-8", errors="replace").splitlines(): for line in JAMENDO_ENV.read_text(encoding="utf-8", errors="replace").splitlines():
line = line.strip() line = line.strip()
if not line or line.startswith("#") or "=" not in line: if not line or line.startswith("#") or "=" not in line:
continue continue
key, value = line.split("=", 1) key, value = line.split("=", 1)
if key.strip() == "PIXABAY_API_KEY": if key.strip() == "JAMENDO_CLIENT_ID":
return value.strip().strip("'\"") return value.strip().strip("'\"")
return None return None
def download_pixabay_music(genre: str, out_dir: Path) -> Path | None: def download_jamendo_music(genre: str, out_dir: Path) -> Path | None:
api_key = load_pixabay_api_key() client_id = load_jamendo_client_id()
if not api_key: if not client_id:
print("No audio files found and no Pixabay API key configured; continuing without music.") console("No audio files found and no Jamendo client ID configured; continuing without music.")
return None return None
query = urllib.parse.urlencode({ query = urllib.parse.urlencode({
"key": api_key, "client_id": client_id,
"q": genre, "format": "json",
"media_type": "music", "limit": "50",
"safesearch": "true", "search": genre,
"per_page": "50", "include": "musicinfo",
"audioformat": "mp32",
}) })
request = urllib.request.Request( request = urllib.request.Request(
f"https://pixabay.com/api/audio/?{query}", f"https://api.jamendo.com/v3.0/tracks/?{query}",
headers={"User-Agent": "movmaker/1.0"}, headers={"User-Agent": "movmaker/1.0"},
) )
try: try:
with urllib.request.urlopen(request, timeout=20) as response: with urllib.request.urlopen(request, timeout=20) as response:
data = json.loads(response.read().decode("utf-8")) data = json.loads(response.read().decode("utf-8"))
except Exception as exc: except Exception as exc:
print(f"Could not search Pixabay music: {exc}") console(f"Could not search Jamendo music: {exc}")
return None return None
hits = [hit for hit in data.get("hits", []) if hit.get("audio")] results = [track for track in data.get("results", []) if track.get("audio")]
if not hits: if not results:
print(f"No Pixabay music found for genre/query: {genre}") console(f"No Jamendo music found for genre/query: {genre}")
return None return None
hit = random.choice(hits) track = random.choice(results)
audio_url = hit["audio"] audio_url = track["audio"]
title = hit.get("title") or "pixabay_music" title = track.get("name") or "jamendo_music"
filename = f"pixabay_{hit.get('id', 'music')}_{slugify(title)}.mp3" artist = track.get("artist_name", "")
filename = f"jamendo_{track.get('id', 'music')}_{slugify(title)}.mp3"
out_dir.mkdir(parents=True, exist_ok=True) out_dir.mkdir(parents=True, exist_ok=True)
out = out_dir / filename out = out_dir / filename
print(f"Downloading Pixabay music: {title}") console(f"Downloading Jamendo music: {title}" + (f" by {artist}" if artist else ""))
try: try:
urllib.request.urlretrieve(audio_url, out) urllib.request.urlretrieve(audio_url, out)
except Exception as exc: except Exception as exc:
print(f"Could not download Pixabay music: {exc}") console(f"Could not download Jamendo music: {exc}")
return None return None
attribution = out.with_suffix(".txt") attribution = out.with_suffix(".txt")
musicinfo = track.get("musicinfo", {}) if isinstance(track.get("musicinfo"), dict) else {}
attribution.write_text( attribution.write_text(
"Pixabay music downloaded by movmaker\n" "Jamendo music downloaded by movmaker\n"
f"Title: {title}\n" f"Title: {title}\n"
f"Artist/User: {hit.get('user', '')}\n" f"Artist: {artist}\n"
f"URL: {hit.get('pageURL', '')}\n" f"URL: {track.get('shareurl', '')}\n"
"Source: Pixabay\n", f"License: {musicinfo.get('license_ccurl', '')}\n"
"Source: Jamendo\n",
encoding="utf-8", encoding="utf-8",
) )
return out return out
@ -806,7 +818,7 @@ def build_video(
y_expr = f"h-text_h-62-{block_height - line_idx * line_gap}" y_expr = f"h-text_h-62-{block_height - line_idx * line_gap}"
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='{title_font}':{textfile_arg(caption_line)}:x=(w-text_w)/2:y={y_expr}:"
f"fontsize={caption_size}:fontcolor=white:borderw=1:bordercolor=black:" 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}]"
) )
@ -870,11 +882,13 @@ 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") parser.add_argument("--music-genre", default="cinematic punk rock", help="Jamendo music search query used only when input has no audio files")
args = parser.parse_args(argv) args = parser.parse_args(argv)
global LOG_PATH global LOG_PATH
LOG_PATH = args.input / "movmaker.log" log_dir = args.input / ".logs"
log_dir.mkdir(parents=True, exist_ok=True)
LOG_PATH = log_dir / f"movmaker_{datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
LOG_PATH.write_text("movmaker log\n", encoding="utf-8") LOG_PATH.write_text("movmaker log\n", encoding="utf-8")
require_tool("ffmpeg") require_tool("ffmpeg")
@ -902,7 +916,7 @@ def main(argv: list[str] | None = None) -> int:
video_fade = max(0.0, args.video_fade) video_fade = max(0.0, args.video_fade)
if not audio_files: if not audio_files:
downloaded_audio = download_pixabay_music(args.music_genre, args.input) downloaded_audio = download_jamendo_music(args.music_genre, args.input)
if downloaded_audio: if downloaded_audio:
audio_files.append(downloaded_audio) audio_files.append(downloaded_audio)
@ -921,30 +935,36 @@ def main(argv: list[str] | None = None) -> int:
else: else:
item.duration = item.duration + (2 * fade) item.duration = item.duration + (2 * fade)
print(f"Found {len(media)} media file(s), {len(audio_files)} audio file(s).") console(f"Found {len(media)} media file(s), {len(audio_files)} audio file(s).")
print(f"Output: {output}") console(f"Output: {output}")
print(f"Log: {LOG_PATH}") console(f"Log: {LOG_PATH}")
if data.title or data.date or data.place: if data.title or data.date or data.place:
print("Title data:", " | ".join(x for x in [data.title, data.date, data.place] if x)) console("Title data: " + " | ".join(x for x in [data.title, data.date, data.place] if x))
with tempfile.TemporaryDirectory(prefix="movmaker-") as tmp_name: with tempfile.TemporaryDirectory(prefix="movmaker-") as tmp_name:
tmp = Path(tmp_name) tmp = Path(tmp_name)
jobs = max(1, args.jobs) jobs = max(1, args.jobs)
clips = [tmp / f"clip_{idx:04d}.mp4" for idx in range(len(media))] clips = [tmp / f"clip_{idx:04d}.mp4" for idx in range(len(media))]
if jobs == 1: if jobs == 1:
print("Preparing clips...") console("Preparing clips...")
for item, out in zip(media, clips): total_clips = len(media)
for done, (item, out) in enumerate(zip(media, clips), start=1):
percent = done / total_clips * 100
console(f"Preparing clips {done}/{total_clips} ({percent:.1f}%)")
log_message(f"Preparing {item.kind}: {item.path.name}") log_message(f"Preparing {item.kind}: {item.path.name}")
normalize_clip(item, out, width, height, fps, preset, crf) normalize_clip(item, out, width, height, fps, preset, crf)
else: else:
print(f"Preparing clips with {jobs} parallel jobs") console(f"Preparing clips with {jobs} parallel jobs")
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as executor: with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as executor:
futures = [] futures = []
for item, out in zip(media, clips): for item, out in zip(media, clips):
log_message(f"Preparing {item.kind}: {item.path.name}") log_message(f"Preparing {item.kind}: {item.path.name}")
futures.append(executor.submit(normalize_clip, item, out, width, height, fps, preset, crf)) futures.append(executor.submit(normalize_clip, item, out, width, height, fps, preset, crf))
for future in concurrent.futures.as_completed(futures): total_clips = len(futures)
for done, future in enumerate(concurrent.futures.as_completed(futures), start=1):
future.result() future.result()
percent = done / total_clips * 100
console(f"Preparing clips {done}/{total_clips} ({percent:.1f}%)")
# Use probed normalized duration for accuracy. # Use probed normalized duration for accuracy.
durations = [ffprobe_duration(out) for out in clips] durations = [ffprobe_duration(out) for out in clips]
@ -953,7 +973,7 @@ def main(argv: list[str] | None = None) -> int:
audio = concat_or_mix_audio(audio_files, tmp / "music.m4a") audio = concat_or_mix_audio(audio_files, tmp / "music.m4a")
build_video(clips, durations, audio, output, data, first_media_date, captions, fade, audio_fade, audio_fade_in, video_fade, width, height, fps, preset, crf, tmp) build_video(clips, durations, audio, output, data, first_media_date, captions, fade, audio_fade, audio_fade_in, video_fade, width, height, fps, preset, crf, tmp)
print(f"Done: {output}") console(f"Done: {output}")
return 0 return 0