Minor updates
This commit is contained in:
parent
e512b5012b
commit
8a0901590b
2 changed files with 36 additions and 20 deletions
10
README.md
10
README.md
|
|
@ -23,7 +23,8 @@ 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 Bold for the bottom-left/bottom-right stamps and per-file captions
|
- Noto Sans Bold for the bottom-left/bottom-right stamps
|
||||||
|
- IBM Plex Sans SemiBold for per-file captions
|
||||||
|
|
||||||
Both fonts support common Danish and Croatian characters such as `æ ø å Æ Ø Å č ć ž š đ Č Ć Ž Š Đ`.
|
Both fonts support common Danish and Croatian characters such as `æ ø å Æ Ø Å č ć ž š đ Č Ć Ž Š Đ`.
|
||||||
|
|
||||||
|
|
@ -33,7 +34,7 @@ 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'
|
||||||
sudo apt install fonts-noto-core
|
sudo apt install fonts-noto-core fonts-ibm-plex
|
||||||
fc-cache -f
|
fc-cache -f
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -42,6 +43,7 @@ Check that fonts are available:
|
||||||
```bash
|
```bash
|
||||||
fc-match 'Bebas Neue'
|
fc-match 'Bebas Neue'
|
||||||
fc-match 'Noto Sans'
|
fc-match 'Noto Sans'
|
||||||
|
fc-match 'IBM Plex Sans'
|
||||||
```
|
```
|
||||||
|
|
||||||
## Optional Jamendo music download
|
## Optional Jamendo music download
|
||||||
|
|
@ -124,7 +126,7 @@ Those three lines mean:
|
||||||
If the date line is omitted, movmaker uses the date from the first picture/video and displays it like:
|
If the date line is omitted, movmaker uses the date from the first picture/video and displays it like:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
May 14th 2026
|
May 2026
|
||||||
```
|
```
|
||||||
|
|
||||||
If the location line is omitted and GPS coordinates are found in the first picture/video that has them, movmaker reverse geocodes them and uses that as the location. It tries to return only `city/town/village, country`; if no city/town/village is found, it uses just the country.
|
If the location line is omitted and GPS coordinates are found in the first picture/video that has them, movmaker reverse geocodes them and uses that as the location. It tries to return only `city/town/village, country`; if no city/town/village is found, it uses just the country.
|
||||||
|
|
@ -188,7 +190,7 @@ python3 movmaker.py input --preview
|
||||||
- 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 thin black outline; use `|` to split caption lines
|
- displays optional per-file captions at bottom center with slightly off-white text and a soft shadow; use `|` to split caption lines
|
||||||
- adds persistent bottom-left title/date/place and bottom-right `Bubulescu.Org` stamps
|
- adds persistent bottom-left title/date/place and bottom-right `Bubulescu.Org` 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`
|
||||||
|
|
|
||||||
46
movmaker.py
46
movmaker.py
|
|
@ -466,7 +466,7 @@ def ordinal_day(day: int) -> str:
|
||||||
|
|
||||||
|
|
||||||
def display_date(dt: datetime) -> str:
|
def display_date(dt: datetime) -> str:
|
||||||
return f"{dt.strftime('%B')} {ordinal_day(dt.day)} {dt.year}"
|
return f"{dt.strftime('%B')} {dt.year}"
|
||||||
|
|
||||||
|
|
||||||
def timestamp() -> str:
|
def timestamp() -> str:
|
||||||
|
|
@ -477,14 +477,6 @@ def console(message: str) -> None:
|
||||||
print(f"[{timestamp()}] {ascii_safe(message)}")
|
print(f"[{timestamp()}] {ascii_safe(message)}")
|
||||||
|
|
||||||
|
|
||||||
def estimate_output_size(duration: float, width: int, height: int, fps: int, crf: int) -> int:
|
|
||||||
pixels = width * height
|
|
||||||
quality_factor = max(0.45, (28 - crf) / 8)
|
|
||||||
bitrate_mbps = max(2.0, pixels * fps * quality_factor / 7_000_000)
|
|
||||||
audio_mbps = 0.192
|
|
||||||
return int(duration * (bitrate_mbps + audio_mbps) * 1_000_000 / 8)
|
|
||||||
|
|
||||||
|
|
||||||
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")
|
||||||
|
|
@ -601,6 +593,22 @@ def normalize_clip(item: MediaItem, out: Path, width: int, height: int, fps: int
|
||||||
run(cmd)
|
run(cmd)
|
||||||
|
|
||||||
|
|
||||||
|
def audio_metadata(path: Path) -> tuple[str, str]:
|
||||||
|
try:
|
||||||
|
out = capture([
|
||||||
|
tool_path(FFPROBE, "ffprobe"), "-v", "error",
|
||||||
|
"-show_entries", "format_tags=artist,title",
|
||||||
|
"-of", "json",
|
||||||
|
str(path),
|
||||||
|
])
|
||||||
|
tags = json.loads(out).get("format", {}).get("tags", {})
|
||||||
|
artist = tags.get("artist") or tags.get("ARTIST") or ""
|
||||||
|
title = tags.get("title") or tags.get("TITLE") or ""
|
||||||
|
return str(artist), str(title)
|
||||||
|
except Exception:
|
||||||
|
return "", ""
|
||||||
|
|
||||||
|
|
||||||
def load_jamendo_client_id() -> str | None:
|
def load_jamendo_client_id() -> str | None:
|
||||||
if os.environ.get("JAMENDO_CLIENT_ID"):
|
if os.environ.get("JAMENDO_CLIENT_ID"):
|
||||||
return os.environ["JAMENDO_CLIENT_ID"]
|
return os.environ["JAMENDO_CLIENT_ID"]
|
||||||
|
|
@ -667,6 +675,8 @@ def download_jamendo_music(genre: str, out_dir: Path) -> Path | None:
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
console(f"Could not download Jamendo music: {exc}")
|
console(f"Could not download Jamendo music: {exc}")
|
||||||
return None
|
return None
|
||||||
|
if title or artist:
|
||||||
|
console("Audio metadata: " + " - ".join(x for x in [artist, title] if x))
|
||||||
log_dir = out_dir / ".logs"
|
log_dir = out_dir / ".logs"
|
||||||
log_dir.mkdir(parents=True, exist_ok=True)
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
attribution = log_dir / f"{out.stem}.txt"
|
attribution = log_dir / f"{out.stem}.txt"
|
||||||
|
|
@ -770,7 +780,7 @@ def build_video(
|
||||||
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 = "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf"
|
stamp_font = "/usr/share/fonts/truetype/noto/NotoSans-Bold.ttf"
|
||||||
caption_font = "Montserrat\\:style=Bold"
|
caption_font = "/usr/share/fonts/truetype/ibm-plex/IBMPlexSans-SemiBold.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)
|
||||||
|
|
@ -839,8 +849,8 @@ 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=font='{caption_font}':{textfile_arg(caption_line)}:x=(w-text_w)/2:y={y_expr}:"
|
f"[{caption_label}]drawtext=fontfile='{caption_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=0xF2F2EE:borderw=0:shadowcolor=black@0.80:shadowx=3:shadowy=3:"
|
||||||
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}]"
|
||||||
)
|
)
|
||||||
caption_label = next_label
|
caption_label = next_label
|
||||||
|
|
@ -958,6 +968,10 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
|
|
||||||
console(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).")
|
||||||
console(f"Audio: {audio_files[0] if audio_files else 'none'}")
|
console(f"Audio: {audio_files[0] if audio_files else 'none'}")
|
||||||
|
if audio_files:
|
||||||
|
artist, title = audio_metadata(audio_files[0])
|
||||||
|
if artist or title:
|
||||||
|
console("Audio metadata: " + " - ".join(x for x in [artist, title] if x))
|
||||||
console(f"Output: {output}")
|
console(f"Output: {output}")
|
||||||
console(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:
|
||||||
|
|
@ -989,14 +1003,14 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
|
|
||||||
# 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]
|
||||||
estimated_duration = durations[0] if len(durations) == 1 else durations[0] + sum(durations[1:]) - fade * (len(durations) - 1)
|
|
||||||
estimated_size = estimate_output_size(estimated_duration, width, height, fps, crf)
|
|
||||||
|
|
||||||
captions = [data.captions.get(item.path.name, data.captions.get(str(item.path), "")) for item in media]
|
captions = [data.captions.get(item.path.name, data.captions.get(str(item.path), "")) for item in media]
|
||||||
audio = concat_or_mix_audio(audio_files, tmp / "music.m4a")
|
audio = concat_or_mix_audio(audio_files, tmp / "music.m4a")
|
||||||
if len(audio_files) > 1:
|
if len(audio_files) > 1:
|
||||||
console(f"Audio: {audio if audio else 'none'}")
|
console(f"Audio: {audio if audio else 'none'}")
|
||||||
console(f"Estimated output: video length {estimated_duration:.1f}s, size about {format_bytes(estimated_size)}")
|
if audio:
|
||||||
|
artist, title = audio_metadata(audio)
|
||||||
|
if artist or title:
|
||||||
|
console("Audio metadata: " + " - ".join(x for x in [artist, title] if x))
|
||||||
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)
|
||||||
|
|
||||||
console(f"Done: {output}")
|
console(f"Done: {output}")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue