Refine logging and Jamendo docs
This commit is contained in:
parent
55ee91cae2
commit
c42e44231a
3 changed files with 76 additions and 55 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,6 +5,7 @@ __pycache__/
|
||||||
venv/
|
venv/
|
||||||
|
|
||||||
# Generated videos / outputs
|
# Generated videos / outputs
|
||||||
|
.logs/
|
||||||
movmaker.log
|
movmaker.log
|
||||||
out/
|
out/
|
||||||
*.mp4
|
*.mp4
|
||||||
|
|
|
||||||
18
README.md
18
README.md
|
|
@ -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
|
||||||
|
|
|
||||||
112
movmaker.py
112
movmaker.py
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue